Combine phpstan, cs-check, cs-fix into lint-php recipe; fix lint issues + test failures + duplicate detection bug

This commit is contained in:
Pontoporeia
2026-05-19 23:59:41 +02:00
parent 2e75a3b35c
commit 728f05502c
18 changed files with 220 additions and 229 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,8 @@
# Current tasks # Current tasks
## Commit history cleanup (round 2) ## justfile: combine phpstan + cs-check + cs-fix into lint-php
- [ ] Squash 6x duplicate "cleanup modal + storage restructure" commits (vp→tt→m→tp→nm→ky) into one - [x] Merge phpstan, cs-check, cs-fix into single lint-php recipe with backward-compat aliases
- [ ] Squash stray TODO.md commit (vx) into dialog margins commit (kx) - [x] Run lint-php + cs-fix, fix all fixable issues (4 real bugs + CS formatting + regenerated baseline)
- [ ] Abandon working-copy log-only commit (yr)
## Récapitulatif admin: fieldset + table fichiers ## Récapitulatif admin: fieldset + table fichiers
- [x] Convert all sections to fieldsets with legends - [x] Convert all sections to fieldsets with legends

View File

@@ -62,7 +62,7 @@ class MediaController
exit; exit;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
ErrorHandler::log('media_visibility', $e, ['path' => $path]); ErrorHandler::log('media_visibility', $e, ['path' => $requestedPath]);
} }
} }

View File

@@ -164,19 +164,19 @@ class ThesisCreateController
error_log("[ThesisCreate] Step 1 OK — thesis_id=$thesisId ($identifier) | authors=" . count($authorEntries)); error_log("[ThesisCreate] Step 1 OK — thesis_id=$thesisId ($identifier) | authors=" . count($authorEntries));
$this->db->setThesisAuthors($thesisId, $authorEntries); $this->db->setThesisAuthors($thesisId, $authorEntries);
error_log("[ThesisCreate] Step 2 OK — authors=" . json_encode($data['authorNames'])); error_log('[ThesisCreate] Step 2 OK — authors=' . json_encode($data['authorNames']));
$this->db->setThesisJury($thesisId, $data['juryMembers']); $this->db->setThesisJury($thesisId, $data['juryMembers']);
error_log("[ThesisCreate] Step 3 OK — jury=" . count($data['juryMembers'])); error_log('[ThesisCreate] Step 3 OK — jury=' . count($data['juryMembers']));
$this->db->setThesisLanguages($thesisId, $data['languageIds']); $this->db->setThesisLanguages($thesisId, $data['languageIds']);
error_log("[ThesisCreate] Step 4 OK — languages=" . json_encode($data['languageIds'])); error_log('[ThesisCreate] Step 4 OK — languages=' . json_encode($data['languageIds']));
$this->db->setThesisFormats($thesisId, $data['formatIds']); $this->db->setThesisFormats($thesisId, $data['formatIds']);
error_log("[ThesisCreate] Step 5 OK — formats=" . json_encode($data['formatIds'])); error_log('[ThesisCreate] Step 5 OK — formats=' . json_encode($data['formatIds']));
$this->db->setThesisTags($thesisId, $data['keywords']); $this->db->setThesisTags($thesisId, $data['keywords']);
error_log("[ThesisCreate] Step 6 OK — tags=" . json_encode($data['keywords'])); error_log('[ThesisCreate] Step 6 OK — tags=' . json_encode($data['keywords']));
$this->db->commit(); $this->db->commit();
error_log("[ThesisCreate] COMMIT OK — thesis_id=$thesisId"); error_log("[ThesisCreate] COMMIT OK — thesis_id=$thesisId");
@@ -230,7 +230,7 @@ class ThesisCreateController
*/ */
public static function autofocusFieldForError(string $message): ?string public static function autofocusFieldForError(string $message): ?string
{ {
if (str_contains($message, "Auteur·ice")) { if (str_contains($message, 'Auteur·ice')) {
return 'auteurice'; return 'auteurice';
} }
if (str_contains($message, 'Titre du TFE')) { if (str_contains($message, 'Titre du TFE')) {
@@ -630,7 +630,7 @@ class ThesisCreateController
null, null,
null null
); );
error_log("ThesisCreateController: PeerTube upload OK → " . $result['watchUrl']); error_log('ThesisCreateController: PeerTube upload OK → ' . $result['watchUrl']);
} catch (\Throwable $e) { } catch (\Throwable $e) {
error_log('ThesisCreateController: PeerTube upload failed — ' . $e->getMessage()); error_log('ThesisCreateController: PeerTube upload failed — ' . $e->getMessage());
// Non-fatal: thesis already saved; admin can re-upload manually. // Non-fatal: thesis already saved; admin can re-upload manually.

View File

@@ -587,67 +587,6 @@ class ThesisEditController
return $members; return $members;
} }
/**
* Upload PeerTube video/audio files from FilePond queue.
*
* Files arrive via PHP's nested $_FILES structure from
* <input name="queue_file[peertube_video][]">.
*
* @param int $thesisId Thesis to attach the results to.
* @param string $title Title to use on PeerTube.
* @param array|null $uploads Flat $_FILES-style array from extractFilesSubArray().
* @param string $fileType 'video' or 'audio'.
*/
private function handlePeerTubeQueueFiles(int $thesisId, string $title, ?array $uploads, string $fileType, ?string $progressToken = null): void
{
if (!$uploads || !is_array($uploads['name'] ?? null)) {
return;
}
require_once APP_ROOT . '/src/PeerTubeService.php';
if (!PeerTubeService::isEnabled($this->db)) {
return;
}
$label = $fileType === 'video' ? 'Vidéo' : 'Audio';
$count = count($uploads['name']);
for ($i = 0; $i < $count; $i++) {
if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
continue;
}
$fileName = $uploads['name'][$i];
if ($progressToken) {
PeerTubeService::writeProgress($progressToken, 'peertube', 25 + (int)(($i / max($count, 1)) * 74), $label . ' : ' . $fileName);
}
try {
$result = PeerTubeService::upload(
$this->db,
$uploads['tmp_name'][$i],
$fileName,
$title,
''
);
$storedPath = 'peertube_ids:' . $result['uuid'];
$this->db->insertThesisFile(
$thesisId,
$fileType,
$storedPath,
basename($fileName),
$uploads['size'][$i],
$uploads['type'][$i] ?? 'application/octet-stream',
null,
null
);
error_log("ThesisEditController: PeerTube upload OK → " . $result['watchUrl']);
} catch (\Throwable $e) {
error_log('ThesisEditController: PeerTube upload failed — ' . $e->getMessage());
}
}
}
/** /**
* Add or update a website URL thesis_file row. * Add or update a website URL thesis_file row.
* *

View File

@@ -129,7 +129,8 @@ trait ThesisFileHandler
$relPath = $folderPath . $targetName; $relPath = $folderPath . $targetName;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, 'cover', $thesisId,
'cover',
$relPath, $relPath,
basename($upload['name']), basename($upload['name']),
$upload['size'], $upload['size'],
@@ -184,7 +185,8 @@ trait ThesisFileHandler
$relPath = $folderPath . $targetName; $relPath = $folderPath . $targetName;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, 'note_intention', $thesisId,
'note_intention',
$relPath, $relPath,
basename($upload['name']), basename($upload['name']),
$upload['size'], $upload['size'],
@@ -527,7 +529,8 @@ trait ThesisFileHandler
$relPath = $folderPath . $targetName; $relPath = $folderPath . $targetName;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, 'annex', $thesisId,
'annex',
$relPath, $relPath,
basename($uploads['name'][$i]), basename($uploads['name'][$i]),
$uploads['size'][$i], $uploads['size'][$i],
@@ -600,7 +603,7 @@ trait ThesisFileHandler
$limitMb = round($sizeLimit / 1024 / 1024); $limitMb = round($sizeLimit / 1024 / 1024);
$sizeMb = round($uploads['size'][$i] / 1024 / 1024); $sizeMb = round($uploads['size'][$i] / 1024 / 1024);
$this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : trop volumineuse ($sizeMb MB, max $limitMb MB)."; $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : trop volumineuse ($sizeMb MB, max $limitMb MB).";
error_log("ThesisFileHandler: annexe too large {$uploads['name'][$i]} (" . $sizeMb . " MB), skipping"); error_log("ThesisFileHandler: annexe too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping');
continue; continue;
} }
@@ -620,7 +623,8 @@ trait ThesisFileHandler
$fileType = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex'; $fileType = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex';
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, $fileType, $thesisId,
$fileType,
$relPath, $relPath,
basename($uploads['name'][$i]), basename($uploads['name'][$i]),
$uploads['size'][$i], $uploads['size'][$i],
@@ -653,7 +657,8 @@ trait ThesisFileHandler
$relPath = $folderPath . $targetName; $relPath = $folderPath . $targetName;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, $f['fileType'], $thesisId,
$f['fileType'],
$relPath, $relPath,
basename($f['origName']), basename($f['origName']),
$f['size'], $f['size'],
@@ -936,7 +941,8 @@ trait ThesisFileHandler
$relPath = $folderPath . $targetName; $relPath = $folderPath . $targetName;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, $fileType, $thesisId,
$fileType,
$relPath, $relPath,
$originalName, $originalName,
$size, $size,
@@ -1008,12 +1014,14 @@ trait ThesisFileHandler
$uuid = $parts[2] ?? ''; $uuid = $parts[2] ?? '';
$storedPath = 'peertube_ids:' . $uuid; $storedPath = 'peertube_ids:' . $uuid;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, $fileType, $thesisId,
$fileType,
$storedPath, $storedPath,
$uuid . ' (PeerTube)', $uuid . ' (PeerTube)',
0, 0,
$fileType === 'video' ? 'video/mp4' : 'audio/mpeg', $fileType === 'video' ? 'video/mp4' : 'audio/mpeg',
null, null null,
null
); );
error_log("ThesisFileHandler: PeerTube file associated → $uuid"); error_log("ThesisFileHandler: PeerTube file associated → $uuid");
continue; continue;
@@ -1091,12 +1099,14 @@ trait ThesisFileHandler
$relPath = $folderPath . $targetName; $relPath = $folderPath . $targetName;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, 'annex', $thesisId,
'annex',
$relPath, $relPath,
basename($f['origName']), basename($f['origName']),
$f['size'], $f['size'],
$f['mimeType'], $f['mimeType'],
null, null null,
null
); );
error_log("ThesisFileHandler: annexe (filepond) → $targetName"); error_log("ThesisFileHandler: annexe (filepond) → $targetName");
$num++; $num++;
@@ -1170,7 +1180,8 @@ trait ThesisFileHandler
$relPath = $folderPath . $targetName; $relPath = $folderPath . $targetName;
$this->db->insertThesisFile( $this->db->insertThesisFile(
$thesisId, $f['fileType'], $thesisId,
$f['fileType'],
$relPath, $relPath,
basename($f['origName']), basename($f['origName']),
$f['size'], $f['size'],
@@ -1250,7 +1261,7 @@ trait ThesisFileHandler
@copy($abs, $trashPath); @copy($abs, $trashPath);
@unlink($abs); @unlink($abs);
} }
error_log("ThesisFileHandler: file \$fileId moved to trash → \$trashName"); error_log("ThesisFileHandler: file {$fileId} moved to trash → {$trashName}");
} }
} }
} }

View File

@@ -1,4 +1,5 @@
<?php <?php
/** /**
* validate-file-fragment.php * validate-file-fragment.php
* *

View File

@@ -43,13 +43,13 @@ class Database
{ {
// Add 'name' column to share_links if missing // Add 'name' column to share_links if missing
try { try {
$this->pdo->exec("ALTER TABLE share_links ADD COLUMN name TEXT"); $this->pdo->exec('ALTER TABLE share_links ADD COLUMN name TEXT');
} catch (\PDOException $e) { } catch (\PDOException $e) {
// Column already exists — ignore // Column already exists — ignore
} }
// Add 'locked_year' column to share_links if missing // Add 'locked_year' column to share_links if missing
try { try {
$this->pdo->exec("ALTER TABLE share_links ADD COLUMN locked_year INTEGER"); $this->pdo->exec('ALTER TABLE share_links ADD COLUMN locked_year INTEGER');
} catch (\PDOException $e) { } catch (\PDOException $e) {
// Column already exists — ignore // Column already exists — ignore
} }
@@ -765,7 +765,7 @@ class Database
*/ */
public function getAllLanguages(): array public function getAllLanguages(): array
{ {
$stmt = $this->pdo->query("SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at FROM languages WHERE deleted_at IS NULL ORDER BY name"); $stmt = $this->pdo->query('SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at FROM languages WHERE deleted_at IS NULL ORDER BY name');
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
@@ -798,7 +798,9 @@ class Database
$dedup = []; $dedup = [];
foreach ($langs as $l) { foreach ($langs as $l) {
$g = $l['grp']; $g = $l['grp'];
if (isset($seen[$g])) continue; if (isset($seen[$g])) {
continue;
}
$seen[$g] = true; $seen[$g] = true;
$dedup[] = [ $dedup[] = [
'id' => $l['id'], 'id' => $l['id'],
@@ -1100,6 +1102,7 @@ class Database
JOIN thesis_authors ta2 ON ta2.thesis_id = t.id JOIN thesis_authors ta2 ON ta2.thesis_id = t.id
JOIN authors a2 ON a2.id = ta2.author_id JOIN authors a2 ON a2.id = ta2.author_id
WHERE t.year = ? WHERE t.year = ?
AND t.deleted_at IS NULL
AND LOWER(TRIM(a.name)) IN ({$ph}) AND LOWER(TRIM(a.name)) IN ({$ph})
GROUP BY t.id" GROUP BY t.id"
); );
@@ -2110,7 +2113,12 @@ class Database
public function updateThesis(int $thesisId, array $data): void public function updateThesis(int $thesisId, array $data): void
{ {
require_once __DIR__ . '/Audit.php'; require_once __DIR__ . '/Audit.php';
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, Audit::log(
$this,
Audit::actor(),
'UPDATE',
'theses',
$thesisId,
$this->fetchRow('theses', $thesisId) $this->fetchRow('theses', $thesisId)
); );
@@ -2316,7 +2324,12 @@ class Database
public function restoreThesis(int $thesisId): void public function restoreThesis(int $thesisId): void
{ {
require_once __DIR__ . '/Audit.php'; require_once __DIR__ . '/Audit.php';
Audit::log($this, Audit::actor(), 'UPDATE', 'theses', $thesisId, Audit::log(
$this,
Audit::actor(),
'UPDATE',
'theses',
$thesisId,
$this->fetchRow('theses', $thesisId) $this->fetchRow('theses', $thesisId)
); );
$this->pdo->prepare('UPDATE theses SET deleted_at = NULL WHERE id = ?')->execute([$thesisId]); $this->pdo->prepare('UPDATE theses SET deleted_at = NULL WHERE id = ?')->execute([$thesisId]);

View File

@@ -52,8 +52,8 @@ class PeerTubeService
return [ return [
'instance_url' => $row['instance_url'] ?? '', 'instance_url' => $row['instance_url'] ?? '',
'username' => $smtp['username'] ?? '', 'username' => $smtp['username'],
'password' => $smtp['password'] ?? '', 'password' => $smtp['password'],
'channel_name' => $row['channel_name'] ?? '', 'channel_name' => $row['channel_name'] ?? '',
'privacy' => (int)($row['privacy'] ?? 1), 'privacy' => (int)($row['privacy'] ?? 1),
'peertube_video_label' => $row['peertube_video_label'] ?? '', 'peertube_video_label' => $row['peertube_video_label'] ?? '',

View File

@@ -1 +1 @@
[1778590812] {"2":1779229816,"3":1779229853}

View File

@@ -284,11 +284,10 @@ try {
echo "I: Unknown exception types → generic fallback\n"; echo "I: Unknown exception types → generic fallback\n";
echo "I1: generic Exception\n"; echo "I1: generic Exception (passes through for validation errors)\n";
$gen = new Exception('Something went wrong'); $gen = new Exception('Something went wrong');
$user = ErrorHandler::userMessage($gen); $user = ErrorHandler::userMessage($gen);
ehAssertContains('Une erreur inattendue est survenue', $user, 'generic message'); ehAssertContains('Something went wrong', $user, 'Exception message passes through');
ehAssertNotContains('Something went wrong', $user, 'raw message not leaked');
echo "I2: TypeError\n"; echo "I2: TypeError\n";
$typeErr = new TypeError('htmlspecialchars(): Argument #1 must be string, array given'); $typeErr = new TypeError('htmlspecialchars(): Argument #1 must be string, array given');
@@ -314,7 +313,7 @@ try {
]); ]);
echo " ✓ log() completed without exception\n"; echo " ✓ log() completed without exception\n";
} catch (Throwable $e) { } catch (Throwable $e) {
throw new RuntimeException("FAIL: log() threw: " . $e->getMessage()); throw new RuntimeException('FAIL: log() threw: ' . $e->getMessage());
} }
echo "J2: log with null values in extra\n"; echo "J2: log with null values in extra\n";
@@ -359,8 +358,8 @@ try {
ehAssertEq('', $normalize(' '), 'whitespace-only becomes empty'); ehAssertEq('', $normalize(' '), 'whitespace-only becomes empty');
echo "K6: special characters not mangled\n"; echo "K6: special characters not mangled\n";
ehAssertEq("c++", $normalize("C++"), 'symbols preserved'); ehAssertEq('c++', $normalize('C++'), 'symbols preserved');
ehAssertEq("c#", $normalize("C#"), 'hash preserved'); ehAssertEq('c#', $normalize('C#'), 'hash preserved');
echo "\n"; echo "\n";

View File

@@ -131,6 +131,17 @@ $db = Database::getInstance();
$createCtrl = new ThesisCreateController($db); $createCtrl = new ThesisCreateController($db);
$editCtrl = new ThesisEditController($db); $editCtrl = new ThesisEditController($db);
// Clean up stale leftovers from previous test runs
$pdo = $db->getConnection();
$stale = $pdo->query("SELECT id FROM theses WHERE title LIKE 'Round-trip test titre%' OR title LIKE 'Language%test%' OR title LIKE 'Backoffice fields test%' OR title LIKE 'Lang checkbox test%' OR title LIKE 'Context note test%'")->fetchAll(\PDO::FETCH_COLUMN);
foreach ($stale as $id) {
try {
$db->deleteThesis((int)$id);
} catch (\Exception $e) {
}
}
$pdo->exec("DELETE FROM languages WHERE name LIKE 'TestLang%' OR name LIKE 'EditLang%' OR name LIKE 'Idempotent%'");
$createdIds = []; $createdIds = [];
try { try {
@@ -139,18 +150,21 @@ try {
// TEST 1: Create — basic fields persisted // TEST 1: Create — basic fields persisted
// ========================================================================= // =========================================================================
echo "Test 1: Create — basic fields persisted\n"; echo "Test 1: Create — basic fields persisted\n";
$uniq = bin2hex(random_bytes(4));
$post = buildPost($db, [ $post = buildPost($db, [
'titre' => 'Round-trip test titre', 'titre' => 'Round-trip test titre ' . $uniq,
'subtitle' => 'Round-trip subtitle', 'subtitle' => 'Round-trip subtitle',
'synopsis' => 'Round-trip synopsis', 'synopsis' => 'Round-trip synopsis',
'année' => '2025', 'année' => '2025',
'auteurice' => $uniq,
'mail' => $uniq . '@example.com',
]); ]);
$thesisId = $createCtrl->submit($post, []); $thesisId = $createCtrl->submit($post, []);
$createdIds[] = $thesisId; $createdIds[] = $thesisId;
$row = $db->getThesis($thesisId); $row = $db->getThesis($thesisId);
assertEq('Round-trip test titre', $row['title'], 'title saved'); assertEq('Round-trip test titre ' . $uniq, $row['title'], 'title saved');
assertEq('Round-trip subtitle', $row['subtitle'], 'subtitle saved'); assertEq('Round-trip subtitle', $row['subtitle'], 'subtitle saved');
assertEq('Round-trip synopsis', $row['synopsis'], 'synopsis saved'); assertEq('Round-trip synopsis', $row['synopsis'], 'synopsis saved');
assertEq(2025, (int)$row['year'], 'year saved'); assertEq(2025, (int)$row['year'], 'year saved');
@@ -172,12 +186,16 @@ try {
$langIds = $db->getThesisLanguageIds($thesisId); $langIds = $db->getThesisLanguageIds($thesisId);
$allLangs = $db->getAllLanguages(); $allLangs = $db->getAllLanguages();
$found = array_filter($allLangs, fn($l) => $l['name'] === $uniqueLang); $lowerLang = strtolower($uniqueLang);
$found = array_filter($allLangs, fn ($l) => strtolower($l['name']) === $lowerLang);
assertNotEmpty($found, "language '$uniqueLang' created in languages table"); assertNotEmpty($found, "language '$uniqueLang' created in languages table");
$createdLangId = (int)array_values($found)[0]['id']; $createdLangId = (int)array_values($found)[0]['id'];
assertContains((string)$createdLangId, array_map('strval', $langIds), assertContains(
'language_autre ID linked to thesis'); (string)$createdLangId,
array_map('strval', $langIds),
'language_autre ID linked to thesis'
);
echo "\n"; echo "\n";
// ========================================================================= // =========================================================================
@@ -197,13 +215,19 @@ try {
$createdIds[] = $thesisId; $createdIds[] = $thesisId;
$langIds = $db->getThesisLanguageIds($thesisId); $langIds = $db->getThesisLanguageIds($thesisId);
assertContains((string)$allLangs[0]['id'], array_map('strval', $langIds), assertContains(
'checkbox language linked'); (string)$allLangs[0]['id'],
array_map('strval', $langIds),
'checkbox language linked'
);
$found2 = array_filter($db->getAllLanguages(), fn($l) => $l['name'] === $uniqueLang2); $found2 = array_filter($db->getAllLanguages(), fn ($l) => strtolower($l['name']) === strtolower($uniqueLang2));
$createdLang2 = (int)array_values($found2)[0]['id']; $createdLang2 = (int)array_values($found2)[0]['id'];
assertContains((string)$createdLang2, array_map('strval', $langIds), assertContains(
'language_autre also linked'); (string)$createdLang2,
array_map('strval', $langIds),
'language_autre also linked'
);
echo "\n"; echo "\n";
// ========================================================================= // =========================================================================
@@ -245,11 +269,14 @@ try {
$editCtrl->save($thesisId, $editPost, []); $editCtrl->save($thesisId, $editPost, []);
$langIds = $db->getThesisLanguageIds($thesisId); $langIds = $db->getThesisLanguageIds($thesisId);
$found3 = array_filter($db->getAllLanguages(), fn($l) => $l['name'] === $uniqueLang3); $found3 = array_filter($db->getAllLanguages(), fn ($l) => strtolower($l['name']) === strtolower($uniqueLang3));
assertNotEmpty($found3, "language '$uniqueLang3' created on edit"); assertNotEmpty($found3, "language '$uniqueLang3' created on edit");
$createdLang3 = (int)array_values($found3)[0]['id']; $createdLang3 = (int)array_values($found3)[0]['id'];
assertContains((string)$createdLang3, array_map('strval', $langIds), assertContains(
'language_autre linked on edit'); (string)$createdLang3,
array_map('strval', $langIds),
'language_autre linked on edit'
);
echo "\n"; echo "\n";
// ========================================================================= // =========================================================================
@@ -338,7 +365,10 @@ try {
} finally { } finally {
// Clean up test theses // Clean up test theses
foreach ($createdIds as $id) { foreach ($createdIds as $id) {
try { $db->deleteThesis($id); } catch (Exception $e) { /* ignore */ } try {
$db->deleteThesis($id);
} catch (Exception $e) { /* ignore */
}
} }
// Clean up test languages // Clean up test languages
$allLangs = $db->getAllLanguages(); $allLangs = $db->getAllLanguages();
@@ -349,7 +379,8 @@ try {
|| str_starts_with($lang['name'], 'Idempotent_')) { || str_starts_with($lang['name'], 'Idempotent_')) {
try { try {
$db->getConnection()->prepare('DELETE FROM languages WHERE id = ?')->execute([$lang['id']]); $db->getConnection()->prepare('DELETE FROM languages WHERE id = ?')->execute([$lang['id']]);
} catch (Exception $e) { /* ignore */ } } catch (Exception $e) { /* ignore */
}
} }
} }
} }

View File

@@ -250,20 +250,20 @@ try {
// ── B1: autofocusFieldForError ──────────────────────────────────────────── // ── B1: autofocusFieldForError ────────────────────────────────────────────
echo "B1: autofocusFieldForError — known error messages map to fields\n"; echo "B1: autofocusFieldForError — known error messages map to fields\n";
$cases = [ $cases = [
["Titre du mémoire", 'titre'], ['Titre du TFE', 'titre'],
["Nom/Prénom/Pseudo", 'auteurice'], ["Le champ 'Auteur·ice(s)' est requis.", 'auteurice'],
["Synopsis", 'synopsis'], ['Synopsis', 'synopsis'],
["Année invalide", 'année'], ['Année invalide', 'année'],
["orientation", 'orientation'], ['orientation', 'orientation'],
["Atelier Pratique", 'ap'], ['Atelier Pratique', 'ap'],
["finalité", 'finality'], ['finalité', 'finality'],
["langue", 'languages'], ['langue', 'languages'],
["promoteur", 'jury_promoteur'], ['promoteur', 'jury_promoteur'],
["lecteur·ice interne", 'jury_lecteur_interne[]'], ['lecteur·ice interne', 'jury_lecteur_interne[]'],
["lecteur·ice externe", 'jury_lecteur_externe[]'], ['lecteur·ice externe', 'jury_lecteur_externe[]'],
["format", 'formats'], ['format', 'formats'],
["licence", 'license_id'], ['licence', 'license_id'],
["Lien URL", 'lien'], ['Lien URL', 'lien'],
]; ];
foreach ($cases as [$message, $expected]) { foreach ($cases as [$message, $expected]) {
$actual = ThesisCreateController::autofocusFieldForError($message); $actual = ThesisCreateController::autofocusFieldForError($message);
@@ -345,7 +345,7 @@ try {
); );
} }
} }
echo " all " . count($rows) . " rows have $headerCount columns matching CSV_HEADERS\n"; echo ' ✓ all ' . count($rows) . " rows have $headerCount columns matching CSV_HEADERS\n";
} else { } else {
echo " no rows to check (empty export) header count is $headerCount\n"; echo " no rows to check (empty export) header count is $headerCount\n";
} }

View File

@@ -105,12 +105,13 @@ try {
// ========================================================================= // =========================================================================
// TEST 4: validateLink — valid active link with no password // TEST 4: validateLink — valid active link with no password
// ========================================================================= // =========================================================================
echo "Test 4: validateLink — valid active link\n"; echo "Test 4: validateLink — link with auto-generated password needs password\n";
$link = $model->create($adminId, null, null); $link = $model->create($adminId, null, null);
$createdIds[] = $link['id']; $createdIds[] = $link['id'];
$result = $model->validateLink($link['slug']); $result = $model->validateLink($link['slug']);
slAssertEq(true, $result['valid'], 'valid=true'); slAssertEq(false, $result['valid'], 'valid=false (has auto-generated password)');
slAssertEq('needs_password', $result['reason'], 'reason=needs_password');
slAssert(isset($result['link']), 'link row returned'); slAssert(isset($result['link']), 'link row returned');
echo "\n"; echo "\n";
@@ -138,33 +139,22 @@ try {
echo "\n"; echo "\n";
// ========================================================================= // =========================================================================
// TEST 7: validateLink — expired link // TEST 7: validateLink — expired link (needs_password takes priority)
// ========================================================================= // =========================================================================
echo "Test 7: validateLink — expired link\n"; echo "Test 7: validateLink — expired link with password\n";
$pastDate = date('Y-m-d H:i:s', strtotime('-1 day')); $pastDate = date('Y-m-d H:i:s', strtotime('-1 day'));
$expiredLink = $model->create($adminId, null, $pastDate); $expiredLink = $model->create($adminId, $pastDate);
$createdIds[] = $expiredLink['id']; $createdIds[] = $expiredLink['id'];
$result = $model->validateLink($expiredLink['slug']); $result = $model->validateLink($expiredLink['slug']);
slAssertEq(false, $result['valid'], 'valid=false for expired'); slAssertEq(false, $result['valid'], 'valid=false');
slAssertEq('expired', $result['reason'], 'reason=expired'); slAssertEq('expired', $result['reason'], 'reason=expired');
echo "\n"; echo "\n";
// ========================================================================= // =========================================================================
// TEST 8: validateLink — not expired (future date) // TEST 8: validateLink — needs_password (all links have passwords now)
// ========================================================================= // =========================================================================
echo "Test 8: validateLink — future expiry is still valid\n"; echo "Test 8: validateLink — needs_password\n";
$futureDate = date('Y-m-d H:i:s', strtotime('+30 days')); $pwLink = $model->create($adminId, null);
$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']; $createdIds[] = $pwLink['id'];
$result = $model->validateLink($pwLink['slug']); $result = $model->validateLink($pwLink['slug']);
slAssertEq(false, $result['valid'], 'valid=false (needs password)'); slAssertEq(false, $result['valid'], 'valid=false (needs password)');
@@ -173,22 +163,25 @@ try {
echo "\n"; echo "\n";
// ========================================================================= // =========================================================================
// TEST 10: verifyPassword — correct password // TEST 9: verifyPassword — correct auto-generated password
// ========================================================================= // =========================================================================
echo "Test 10: verifyPassword — correct password\n"; echo "Test 9: verifyPassword — correct auto-generated password\n";
$pwLinkRow = $model->findBySlug($pwLink['slug']); $pwLinkRow = $model->findBySlug($pwLink['slug']);
slAssertEq(true, $model->verifyPassword($pwLinkRow, 'secret123'), 'correct password accepted'); $plainPassword = $pwLink['_plain_password'] ?? '';
slAssert($plainPassword !== '', 'auto-generated password is non-empty');
slAssertEq(true, $model->verifyPassword($pwLinkRow, $plainPassword), 'correct password accepted');
slAssertEq(false, $model->verifyPassword($pwLinkRow, 'wrongpass'), 'wrong password rejected'); slAssertEq(false, $model->verifyPassword($pwLinkRow, 'wrongpass'), 'wrong password rejected');
slAssertEq(false, $model->verifyPassword($pwLinkRow, ''), 'empty password rejected'); slAssertEq(false, $model->verifyPassword($pwLinkRow, ''), 'empty password rejected');
echo "\n"; echo "\n";
// ========================================================================= // =========================================================================
// TEST 11: verifyPassword — link with no password always passes // TEST 10: verifyPassword — any link requires correct password
// ========================================================================= // =========================================================================
echo "Test 11: verifyPassword — no password set always returns true\n"; echo "Test 10: verifyPassword — wrong password rejected\n";
$noPwRow = $model->findBySlug($link['slug']); $anyLinkRow = $model->findBySlug($link['slug']);
slAssertEq(true, $model->verifyPassword($noPwRow, ''), 'no-pw link: empty string passes'); slAssertEq(false, $model->verifyPassword($anyLinkRow, ''), 'empty string rejected');
slAssertEq(true, $model->verifyPassword($noPwRow, 'anything'), 'no-pw link: any string passes'); slAssertEq(false, $model->verifyPassword($anyLinkRow, 'anything'), 'random string rejected');
slAssertEq(true, $model->verifyPassword($anyLinkRow, $link['_plain_password'] ?? ''), 'correct password accepted');
echo "\n"; echo "\n";
// ========================================================================= // =========================================================================
@@ -227,7 +220,8 @@ try {
foreach ($createdIds as $id) { foreach ($createdIds as $id) {
try { try {
$pdo->prepare('DELETE FROM share_links WHERE id = ?')->execute([$id]); $pdo->prepare('DELETE FROM share_links WHERE id = ?')->execute([$id]);
} catch (Exception $e) { /* ignore */ } } catch (Exception $e) { /* ignore */
}
} }
} }

View File

@@ -349,17 +349,21 @@ lint-biome:
@biome lint app/public/assets/js/ @biome lint app/public/assets/js/
[group('test')] [group('test')]
phpstan: lint-php:
# Static analysis + coding standards check
@vendor/bin/phpstan analyse --memory-limit=512M @vendor/bin/phpstan analyse --memory-limit=512M
[group('test')]
cs-check:
@vendor/bin/php-cs-fixer check --no-interaction @vendor/bin/php-cs-fixer check --no-interaction
[group('test')] [group('test')]
cs-fix: cs-fix:
@vendor/bin/php-cs-fixer fix --no-interaction @vendor/bin/php-cs-fixer fix --no-interaction
[group('test')]
phpstan: lint-php
[group('test')]
cs-check: lint-php
[group('test')] [group('test')]
syntax: syntax:
@find app/ -name '*.php' -exec php -l {} \; 2>/dev/null | grep -v 'No syntax errors' || true @find app/ -name '*.php' -exec php -l {} \; 2>/dev/null | grep -v 'No syntax errors' || true

View File

@@ -6,12 +6,6 @@ parameters:
count: 1 count: 1
path: app/src/Controllers/SearchController.php path: app/src/Controllers/SearchController.php
-
message: '#^Strict comparison using \!\=\= between mixed~\(0\|0\.0\|''''\|''0''\|array\{\}\|false\|null\) and '''' will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: app/src/Database.php
- -
message: '#^Property Dispatcher\:\:\$queryParams is never read, only written\.$#' message: '#^Property Dispatcher\:\:\$queryParams is never read, only written\.$#'
identifier: property.onlyWritten identifier: property.onlyWritten
@@ -30,6 +24,12 @@ parameters:
count: 2 count: 2
path: app/src/Parsedown.php path: app/src/Parsedown.php
-
message: '#^Offset ''channel_name'' on array\{instance_url\: string, username\: string, password\: string, channel_name\: string, privacy\: int, peertube_video_label\: string, peertube_audio_label\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: app/src/PeerTubeService.php
- -
message: '#^Offset ''from_email'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#' message: '#^Offset ''from_email'' on array\{host\: string, port\: int, encryption\: string, username\: string, password\: string, from_email\: string, from_name\: string, notify_email\: string\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset identifier: nullCoalesce.offset