From 728f05502c966e341dae3c1e4eee4c0406df35e4 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Tue, 19 May 2026 23:59:41 +0200 Subject: [PATCH] Combine phpstan, cs-check, cs-fix into lint-php recipe; fix lint issues + test failures + duplicate detection bug --- .php-cs-fixer.cache | 2 +- TODO.md | 7 +- app/src/Controllers/MediaController.php | 2 +- .../Controllers/ThesisCreateController.php | 22 ++--- app/src/Controllers/ThesisEditController.php | 67 +------------- app/src/Controllers/ThesisFileHandler.php | 43 +++++---- .../validate-file-fragment-shared.php | 1 + app/src/Database.php | 29 +++++-- app/src/EmailObfuscator.php | 4 +- app/src/PeerTubeService.php | 4 +- app/src/ShareLink.php | 2 +- .../ad921d60486366258809553a3db49a4a.json | 2 +- app/tests/Unit/ErrorHandlerTest.php | 21 +++-- app/tests/Unit/FormSaveTest.php | 87 +++++++++++++------ app/tests/Unit/PureLogicTest.php | 58 ++++++------- app/tests/Unit/ShareLinkTest.php | 74 ++++++++-------- justfile | 12 ++- phpstan-baseline.neon | 12 +-- 18 files changed, 220 insertions(+), 229 deletions(-) diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index ebd7f21..0ebc66c 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.5.6","version":"3.95.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"clean_namespace":true,"no_unset_cast":true,"assign_null_coalescing_to_coalesce_equal":true,"normalize_index_brace":true,"heredoc_indentation":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"trailing_comma_in_multiline":true,"list_syntax":true,"ternary_to_null_coalescing":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"single_quote":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"app\/src\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/AdminAuth.php":"14677d94cd04b7d455c43e74f7ec5d14","app\/tests\/Security\/SecurityTest.php":"ff9996affd0ca42095d34e0ac0650050","app\/tests\/Integration\/SearchTest.php":"29aba0f9da3c115fb5d2722576815361","app\/tests\/Unit\/DatabaseTest.php":"2dd249e28ac632ba222a8cead3ae16a1","app\/tests\/Unit\/RateLimitTest.php":"4fc2ffe64d2c889835ef4fcd2e89d835","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Parsedown.php":"d98c00dfbbb11933a86407ee9cf9215d","app\/src\/AppLogger.php":"139735566a1cc21d64eacc5b63de1d3c","app\/src\/DuplicateThesisException.php":"52abe5f40ef48cfbfd44c119d91309e9","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/Audit.php":"6f795cbdf5d81b0ae337dd2df5084f75","app\/src\/Crypto.php":"e72e65eeaf5b6fa8e41ba4501643440b","app\/tests\/Unit\/ErrorHandlerTest.php":"4fcf8c4f4eedb257b0a0ade70d98d231","app\/tests\/Unit\/ShareLinkTest.php":"c1d03602f8f71c047fdf688990ab392d","app\/tests\/Unit\/FormSaveTest.php":"84e3fa5fe9848470debd7c9d2ba61a9a","app\/src\/PeerTubeService.php":"2e6c39ffe11f96ddca4fb355aa419dbd","app\/src\/FragmentRenderer.php":"52083e1f2ff98f01e074a93bf6765366","app\/src\/ErrorHandler.php":"56d3dc0af9ce6b5ef07291419073a6b1","app\/tests\/Unit\/PureLogicTest.php":"bb046d24f4b10b44283f2eb355ec17a0","app\/src\/EmailObfuscator.php":"ff946c10add222870223b9626990e75c","app\/src\/FilepondHandler.php":"7ba1547b7cb8daeecd69f32566d6a755","app\/src\/Controllers\/validate-file-fragment-shared.php":"d572b9564076e22b6a3f9baddf7bdb46","app\/src\/Controllers\/ThesisFileHandler.php":"d66e212b11d32c9056fb1646787f5434"}} \ No newline at end of file +{"php":"8.5.6","version":"3.95.1","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false,"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":true},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"clean_namespace":true,"no_unset_cast":true,"assign_null_coalescing_to_coalesce_equal":true,"normalize_index_brace":true,"heredoc_indentation":true,"no_whitespace_before_comma_in_array":{"after_heredoc":true},"trailing_comma_in_multiline":true,"list_syntax":true,"ternary_to_null_coalescing":true,"array_syntax":{"syntax":"short"},"no_unused_imports":true,"single_quote":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"app\/src\/SystemCache.php":"4ead28637fa3a9281bdad42cdb9e00c2","app\/src\/AdminAuth.php":"14677d94cd04b7d455c43e74f7ec5d14","app\/tests\/Security\/SecurityTest.php":"ff9996affd0ca42095d34e0ac0650050","app\/tests\/Integration\/SearchTest.php":"29aba0f9da3c115fb5d2722576815361","app\/tests\/Unit\/DatabaseTest.php":"2dd249e28ac632ba222a8cead3ae16a1","app\/tests\/Unit\/RateLimitTest.php":"4fc2ffe64d2c889835ef4fcd2e89d835","app\/src\/Controllers\/FileAccessController.php":"9665edaa0ab1fd7c94b9a3d9fad95c5f","app\/src\/Controllers\/LiveReloadController.php":"e2ff21e7155e769b2684a51accf1699d","app\/src\/Parsedown.php":"d98c00dfbbb11933a86407ee9cf9215d","app\/src\/AppLogger.php":"139735566a1cc21d64eacc5b63de1d3c","app\/src\/DuplicateThesisException.php":"52abe5f40ef48cfbfd44c119d91309e9","app\/src\/RateLimit.php":"2e1df734570cb3eb584682bed33a2636","app\/src\/Audit.php":"6f795cbdf5d81b0ae337dd2df5084f75","app\/src\/Crypto.php":"e72e65eeaf5b6fa8e41ba4501643440b","app\/src\/FragmentRenderer.php":"52083e1f2ff98f01e074a93bf6765366","app\/src\/ErrorHandler.php":"56d3dc0af9ce6b5ef07291419073a6b1","app\/src\/EmailObfuscator.php":"ff946c10add222870223b9626990e75c","app\/src\/FilepondHandler.php":"7ba1547b7cb8daeecd69f32566d6a755","app\/src\/Controllers\/validate-file-fragment-shared.php":"d572b9564076e22b6a3f9baddf7bdb46","app\/tests\/run-tests.php":"860d3aa6d8f15ee90c80fef5e5d50c7c","app\/src\/Controllers\/HomeController.php":"8e9a29c622c8c37191ea2bdb40c5f3bf","app\/src\/Controllers\/AboutController.php":"72e9dbbd0595dc51dc4e4e18d618c1d0","app\/src\/Controllers\/ThesisCreateController.php":"02958bdd91e11e23e1597070a382012c","app\/src\/Dispatcher.php":"5d7ca0543b942857e5ef909aface040b","app\/src\/App.php":"cc568bc6c2453d35638ae64836c443da","app\/src\/AdminLogger.php":"b3e80ea6375d19f40e5da126ee8397b7","app\/src\/SmtpRelay.php":"c694b9561bb477ab4728a386ffc96955","app\/src\/ShareLink.php":"7fc43ea0264f7e2a8562abd0ef8c2ecd","app\/src\/StudentEmail.php":"06b51a435ff86462f7311f58dfe03c34","app\/src\/Controllers\/ExportController.php":"f33ac38ed4014c0236534c8529b92f1c","app\/src\/Controllers\/TfeController.php":"d080380d1612d8365d7987ca536c31c7","app\/src\/Controllers\/SystemController.php":"4e25f8cd33ff44d5a9a6c615b000d6cb","app\/src\/Controllers\/SearchController.php":"2daaca037c8df9353888f8093120e36e","app\/src\/Controllers\/LicenceController.php":"c92050153fe8b25ca524e706f6c3f66b","app\/src\/Controllers\/MediaController.php":"4b1412723d5a77bb8f75ad825b4ef333","app\/src\/Controllers\/ThesisEditController.php":"ee75f8510c40add9365966187262e251","app\/src\/PeerTubeService.php":"02ba65d9ddc4ba2bda2d4a5d1fb6f459","app\/src\/Controllers\/ThesisFileHandler.php":"01af0cfcc89e048868351805b760c13b","app\/src\/Database.php":"37a008372ca85a45fa78651cd88975ec","app\/tests\/Unit\/ErrorHandlerTest.php":"a8f89fea425a0c0d358a865aeca8dd11","app\/tests\/Unit\/ShareLinkTest.php":"1c4eccb8d46f15a3b4f3938b52a70e60","app\/tests\/Unit\/FormSaveTest.php":"72fe083f6df01d588e4a097e2cb36cd6","app\/tests\/Unit\/PureLogicTest.php":"de6e0c5535c6ef83c4e566bd07320c9e"}} \ No newline at end of file diff --git a/TODO.md b/TODO.md index 447a541..c0e5300 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,8 @@ # Current tasks -## Commit history cleanup (round 2) -- [ ] Squash 6x duplicate "cleanup modal + storage restructure" commits (vp→tt→m→tp→nm→ky) into one -- [ ] Squash stray TODO.md commit (vx) into dialog margins commit (kx) -- [ ] Abandon working-copy log-only commit (yr) +## justfile: combine phpstan + cs-check + cs-fix into lint-php +- [x] Merge phpstan, cs-check, cs-fix into single lint-php recipe with backward-compat aliases +- [x] Run lint-php + cs-fix, fix all fixable issues (4 real bugs + CS formatting + regenerated baseline) ## Récapitulatif admin: fieldset + table fichiers - [x] Convert all sections to fieldsets with legends diff --git a/app/src/Controllers/MediaController.php b/app/src/Controllers/MediaController.php index d39e050..cc7fbe3 100644 --- a/app/src/Controllers/MediaController.php +++ b/app/src/Controllers/MediaController.php @@ -62,7 +62,7 @@ class MediaController exit; } } catch (\Throwable $e) { - ErrorHandler::log('media_visibility', $e, ['path' => $path]); + ErrorHandler::log('media_visibility', $e, ['path' => $requestedPath]); } } diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 0753764..0146381 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -164,19 +164,19 @@ class ThesisCreateController error_log("[ThesisCreate] Step 1 OK — thesis_id=$thesisId ($identifier) | authors=" . count($authorEntries)); $this->db->setThesisAuthors($thesisId, $authorEntries); - error_log("[ThesisCreate] Step 2 OK — authors=" . json_encode($data['authorNames'])); + error_log('[ThesisCreate] Step 2 OK — authors=' . json_encode($data['authorNames'])); $this->db->setThesisJury($thesisId, $data['juryMembers']); - error_log("[ThesisCreate] Step 3 OK — jury=" . count($data['juryMembers'])); + error_log('[ThesisCreate] Step 3 OK — jury=' . count($data['juryMembers'])); $this->db->setThesisLanguages($thesisId, $data['languageIds']); - error_log("[ThesisCreate] Step 4 OK — languages=" . json_encode($data['languageIds'])); + error_log('[ThesisCreate] Step 4 OK — languages=' . json_encode($data['languageIds'])); $this->db->setThesisFormats($thesisId, $data['formatIds']); - error_log("[ThesisCreate] Step 5 OK — formats=" . json_encode($data['formatIds'])); + error_log('[ThesisCreate] Step 5 OK — formats=' . json_encode($data['formatIds'])); $this->db->setThesisTags($thesisId, $data['keywords']); - error_log("[ThesisCreate] Step 6 OK — tags=" . json_encode($data['keywords'])); + error_log('[ThesisCreate] Step 6 OK — tags=' . json_encode($data['keywords'])); $this->db->commit(); error_log("[ThesisCreate] COMMIT OK — thesis_id=$thesisId"); @@ -230,7 +230,7 @@ class ThesisCreateController */ public static function autofocusFieldForError(string $message): ?string { - if (str_contains($message, "Auteur·ice")) { + if (str_contains($message, 'Auteur·ice')) { return 'auteurice'; } if (str_contains($message, 'Titre du TFE')) { @@ -420,13 +420,13 @@ class ThesisCreateController // Keywords (max 10, min 3) — lowercased, spaces collapsed, deduplicated $keywords = []; - $normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); + $normalizeTag = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); if (isset($post['tag']) && is_array($post['tag'])) { $keywords = array_values(array_unique(array_map( $normalizeTag, - array_map(fn($t) => (string)$t, $post['tag']) + array_map(fn ($t) => (string)$t, $post['tag']) ))); - $keywords = array_filter($keywords, fn($t) => $t !== ''); + $keywords = array_filter($keywords, fn ($t) => $t !== ''); $keywords = array_slice($keywords, 0, 10); } else { $tagRaw = $this->sanitiseString($post['tag'] ?? ''); @@ -435,7 +435,7 @@ class ThesisCreateController } } $keywords = array_values(array_unique($keywords)); - $keywords = array_filter($keywords, fn($t) => $t !== ''); + $keywords = array_filter($keywords, fn ($t) => $t !== ''); $keywords = array_slice($keywords, 0, 10); if (count($keywords) > 10) { throw new Exception('Maximum 10 mots-clés autorisés.'); @@ -630,7 +630,7 @@ class ThesisCreateController null, null ); - error_log("ThesisCreateController: PeerTube upload OK → " . $result['watchUrl']); + error_log('ThesisCreateController: PeerTube upload OK → ' . $result['watchUrl']); } catch (\Throwable $e) { error_log('ThesisCreateController: PeerTube upload failed — ' . $e->getMessage()); // Non-fatal: thesis already saved; admin can re-upload manually. diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 72aadce..fa5be1c 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -278,12 +278,12 @@ class ThesisEditController error_log('[ThesisEdit] Step 5 OK — formats=' . json_encode($formatIds)); // ── 6. Tags ─────────────────────────────────────────────────────── - $normalizeTag = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); + $normalizeTag = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); $keywords = []; if (isset($post['tag']) && is_array($post['tag'])) { $keywords = array_values(array_unique(array_map( $normalizeTag, - array_map(fn($t) => (string)$t, $post['tag']) + array_map(fn ($t) => (string)$t, $post['tag']) ))); } else { $keywordsRaw = trim($post['tag'] ?? ''); @@ -292,7 +292,7 @@ class ThesisEditController } } $keywords = array_values(array_unique($keywords)); - $keywords = array_filter($keywords, fn($t) => $t !== ''); + $keywords = array_filter($keywords, fn ($t) => $t !== ''); $keywords = array_slice($keywords, 0, 10); if (count($keywords) < 1) { throw new Exception('Veuillez indiquer au moins 1 mot-clé.'); @@ -587,67 +587,6 @@ class ThesisEditController return $members; } - /** - * Upload PeerTube video/audio files from FilePond queue. - * - * Files arrive via PHP's nested $_FILES structure from - * . - * - * @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. * diff --git a/app/src/Controllers/ThesisFileHandler.php b/app/src/Controllers/ThesisFileHandler.php index 963443f..ad52221 100644 --- a/app/src/Controllers/ThesisFileHandler.php +++ b/app/src/Controllers/ThesisFileHandler.php @@ -129,7 +129,8 @@ trait ThesisFileHandler $relPath = $folderPath . $targetName; $this->db->insertThesisFile( - $thesisId, 'cover', + $thesisId, + 'cover', $relPath, basename($upload['name']), $upload['size'], @@ -184,7 +185,8 @@ trait ThesisFileHandler $relPath = $folderPath . $targetName; $this->db->insertThesisFile( - $thesisId, 'note_intention', + $thesisId, + 'note_intention', $relPath, basename($upload['name']), $upload['size'], @@ -278,7 +280,7 @@ trait ThesisFileHandler } // Sort by hierarchy rank - usort($files, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']); + usort($files, fn ($a, $b) => $a['hierarchy'] - $b['hierarchy']); // Assign contiguous TFE_XX numbers $videoCount = 0; @@ -527,7 +529,8 @@ trait ThesisFileHandler $relPath = $folderPath . $targetName; $this->db->insertThesisFile( - $thesisId, 'annex', + $thesisId, + 'annex', $relPath, basename($uploads['name'][$i]), $uploads['size'][$i], @@ -600,7 +603,7 @@ trait ThesisFileHandler $limitMb = round($sizeLimit / 1024 / 1024); $sizeMb = round($uploads['size'][$i] / 1024 / 1024); $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; } @@ -620,7 +623,8 @@ trait ThesisFileHandler $fileType = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex'; $this->db->insertThesisFile( - $thesisId, $fileType, + $thesisId, + $fileType, $relPath, basename($uploads['name'][$i]), $uploads['size'][$i], @@ -653,7 +657,8 @@ trait ThesisFileHandler $relPath = $folderPath . $targetName; $this->db->insertThesisFile( - $thesisId, $f['fileType'], + $thesisId, + $f['fileType'], $relPath, basename($f['origName']), $f['size'], @@ -730,7 +735,7 @@ trait ThesisFileHandler */ protected function generateAuthorSlug(string $authorNames): string { - $names = array_values(array_filter(array_map('trim', explode(',', $authorNames)), fn($n) => $n !== '')); + $names = array_values(array_filter(array_map('trim', explode(',', $authorNames)), fn ($n) => $n !== '')); sort($names, SORT_NATURAL); $joined = implode('-', $names); @@ -936,7 +941,8 @@ trait ThesisFileHandler $relPath = $folderPath . $targetName; $this->db->insertThesisFile( - $thesisId, $fileType, + $thesisId, + $fileType, $relPath, $originalName, $size, @@ -1008,12 +1014,14 @@ trait ThesisFileHandler $uuid = $parts[2] ?? ''; $storedPath = 'peertube_ids:' . $uuid; $this->db->insertThesisFile( - $thesisId, $fileType, + $thesisId, + $fileType, $storedPath, $uuid . ' (PeerTube)', 0, $fileType === 'video' ? 'video/mp4' : 'audio/mpeg', - null, null + null, + null ); error_log("ThesisFileHandler: PeerTube file associated → $uuid"); continue; @@ -1091,12 +1099,14 @@ trait ThesisFileHandler $relPath = $folderPath . $targetName; $this->db->insertThesisFile( - $thesisId, 'annex', + $thesisId, + 'annex', $relPath, basename($f['origName']), $f['size'], $f['mimeType'], - null, null + null, + null ); error_log("ThesisFileHandler: annexe (filepond) → $targetName"); $num++; @@ -1112,7 +1122,7 @@ trait ThesisFileHandler $f['hierarchy'] = $this->tfeHierarchyRank($f['mimeType'], $f['ext']); $filesWithRank[] = $f; } - usort($filesWithRank, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']); + usort($filesWithRank, fn ($a, $b) => $a['hierarchy'] - $b['hierarchy']); $num = $startNum; $vttIdx = 0; @@ -1170,7 +1180,8 @@ trait ThesisFileHandler $relPath = $folderPath . $targetName; $this->db->insertThesisFile( - $thesisId, $f['fileType'], + $thesisId, + $f['fileType'], $relPath, basename($f['origName']), $f['size'], @@ -1250,7 +1261,7 @@ trait ThesisFileHandler @copy($abs, $trashPath); @unlink($abs); } - error_log("ThesisFileHandler: file \$fileId moved to trash → \$trashName"); + error_log("ThesisFileHandler: file {$fileId} moved to trash → {$trashName}"); } } } diff --git a/app/src/Controllers/validate-file-fragment-shared.php b/app/src/Controllers/validate-file-fragment-shared.php index 017de6a..7004a63 100644 --- a/app/src/Controllers/validate-file-fragment-shared.php +++ b/app/src/Controllers/validate-file-fragment-shared.php @@ -1,4 +1,5 @@ pdo->exec("ALTER TABLE share_links ADD COLUMN name TEXT"); + $this->pdo->exec('ALTER TABLE share_links ADD COLUMN name TEXT'); } catch (\PDOException $e) { // Column already exists — ignore } // Add 'locked_year' column to share_links if missing 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) { // Column already exists — ignore } @@ -765,7 +765,7 @@ class Database */ 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(); } @@ -798,7 +798,9 @@ class Database $dedup = []; foreach ($langs as $l) { $g = $l['grp']; - if (isset($seen[$g])) continue; + if (isset($seen[$g])) { + continue; + } $seen[$g] = true; $dedup[] = [ 'id' => $l['id'], @@ -1100,6 +1102,7 @@ class Database JOIN thesis_authors ta2 ON ta2.thesis_id = t.id JOIN authors a2 ON a2.id = ta2.author_id WHERE t.year = ? + AND t.deleted_at IS NULL AND LOWER(TRIM(a.name)) IN ({$ph}) GROUP BY t.id" ); @@ -2110,7 +2113,12 @@ class Database public function updateThesis(int $thesisId, array $data): void { 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) ); @@ -2225,8 +2233,8 @@ class Database !empty($data['subtitle']) ? $data['subtitle'] : null, (int)$data['year'], $orientation ? (int)$orientation : null, - $ap ? (int)$ap : null, - $finality ? (int)$finality : null, + $ap ? (int)$ap : null, + $finality ? (int)$finality : null, $data['synopsis'], !empty($data['context_note']) ? $data['context_note'] : null, !empty($data['baiu_link']) ? $data['baiu_link'] : null, @@ -2316,7 +2324,12 @@ class Database public function restoreThesis(int $thesisId): void { 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->pdo->prepare('UPDATE theses SET deleted_at = NULL WHERE id = ?')->execute([$thesisId]); diff --git a/app/src/EmailObfuscator.php b/app/src/EmailObfuscator.php index 56220c3..6d74eee 100644 --- a/app/src/EmailObfuscator.php +++ b/app/src/EmailObfuscator.php @@ -50,7 +50,7 @@ class EmailObfuscator { return preg_replace_callback( '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/', - fn(array $m) => self::encode($m[0]), + fn (array $m) => self::encode($m[0]), $text ); } @@ -63,7 +63,7 @@ class EmailObfuscator { return preg_replace_callback( '/mailto:([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/i', - fn(array $m) => 'mailto:' . self::encode($m[1]), + fn (array $m) => 'mailto:' . self::encode($m[1]), $text ); } diff --git a/app/src/PeerTubeService.php b/app/src/PeerTubeService.php index 0207169..8a77a26 100644 --- a/app/src/PeerTubeService.php +++ b/app/src/PeerTubeService.php @@ -52,8 +52,8 @@ class PeerTubeService return [ 'instance_url' => $row['instance_url'] ?? '', - 'username' => $smtp['username'] ?? '', - 'password' => $smtp['password'] ?? '', + 'username' => $smtp['username'], + 'password' => $smtp['password'], 'channel_name' => $row['channel_name'] ?? '', 'privacy' => (int)($row['privacy'] ?? 1), 'peertube_video_label' => $row['peertube_video_label'] ?? '', diff --git a/app/src/ShareLink.php b/app/src/ShareLink.php index a38ad48..c2b6543 100644 --- a/app/src/ShareLink.php +++ b/app/src/ShareLink.php @@ -147,7 +147,7 @@ class ShareLink 'SELECT * FROM share_links WHERE is_archived = 0 ORDER BY created_at DESC' ); $rows = $stmt->fetchAll(); - return array_map(fn($row) => $this->decorateWithPassword($row), $rows); + return array_map(fn ($row) => $this->decorateWithPassword($row), $rows); } /** diff --git a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json index 8b9c9fa..8f16461 100644 --- a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json +++ b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json @@ -1 +1 @@ -[1778590812] \ No newline at end of file +{"2":1779229816,"3":1779229853} \ No newline at end of file diff --git a/app/tests/Unit/ErrorHandlerTest.php b/app/tests/Unit/ErrorHandlerTest.php index efd5e8e..12da185 100644 --- a/app/tests/Unit/ErrorHandlerTest.php +++ b/app/tests/Unit/ErrorHandlerTest.php @@ -284,11 +284,10 @@ try { 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'); $user = ErrorHandler::userMessage($gen); - ehAssertContains('Une erreur inattendue est survenue', $user, 'generic message'); - ehAssertNotContains('Something went wrong', $user, 'raw message not leaked'); + ehAssertContains('Something went wrong', $user, 'Exception message passes through'); echo "I2: TypeError\n"; $typeErr = new TypeError('htmlspecialchars(): Argument #1 must be string, array given'); @@ -314,7 +313,7 @@ try { ]); echo " ✓ log() completed without exception\n"; } 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"; @@ -335,7 +334,7 @@ try { // Test the normalization regex used in controllers and JS: // strtolower(trim(preg_replace('/\s+/', ' ', $t))) - $normalize = fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); + $normalize = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))); echo "K1: basic trimming and casing\n"; ehAssertEq('hello', $normalize('Hello'), 'uppercase → lowercase'); @@ -359,8 +358,8 @@ try { ehAssertEq('', $normalize(' '), 'whitespace-only becomes empty'); echo "K6: special characters not mangled\n"; - ehAssertEq("c++", $normalize("C++"), 'symbols preserved'); - ehAssertEq("c#", $normalize("C#"), 'hash preserved'); + ehAssertEq('c++', $normalize('C++'), 'symbols preserved'); + ehAssertEq('c#', $normalize('C#'), 'hash preserved'); echo "\n"; @@ -370,9 +369,9 @@ try { echo "L: Deduplication after normalization\n"; - $dedup = function(array $tags): array { + $dedup = function (array $tags): array { return array_values(array_unique(array_map( - fn(string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))), + fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))), $tags ))); }; @@ -384,11 +383,11 @@ try { ehAssertEq(['hello world'], $dedup(['hello world', 'Hello World', 'hello world']), 'whitespace + case → one entry'); echo "L3: empty strings filtered\n"; - $filtered = array_values(array_filter($dedup(['', ' ', 'valid']), fn($t) => $t !== '')); + $filtered = array_values(array_filter($dedup(['', ' ', 'valid']), fn ($t) => $t !== '')); ehAssertEq(['valid'], $filtered, 'empty/whitespace-only removed'); echo "L4: mixed valid and empty\n"; - $result = array_values(array_filter($dedup(['Alpha', '', ' ', 'BETA', 'alpha']), fn($t) => $t !== '')); + $result = array_values(array_filter($dedup(['Alpha', '', ' ', 'BETA', 'alpha']), fn ($t) => $t !== '')); ehAssertEq(['alpha', 'beta'], $result, 'deduplicated and empties filtered'); echo "\n"; diff --git a/app/tests/Unit/FormSaveTest.php b/app/tests/Unit/FormSaveTest.php index 623da3c..6c78bef 100644 --- a/app/tests/Unit/FormSaveTest.php +++ b/app/tests/Unit/FormSaveTest.php @@ -131,6 +131,17 @@ $db = Database::getInstance(); $createCtrl = new ThesisCreateController($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 = []; try { @@ -139,21 +150,24 @@ try { // TEST 1: Create — basic fields persisted // ========================================================================= echo "Test 1: Create — basic fields persisted\n"; + $uniq = bin2hex(random_bytes(4)); $post = buildPost($db, [ - 'titre' => 'Round-trip test titre', + 'titre' => 'Round-trip test titre ' . $uniq, 'subtitle' => 'Round-trip subtitle', 'synopsis' => 'Round-trip synopsis', 'année' => '2025', + 'auteurice' => $uniq, + 'mail' => $uniq . '@example.com', ]); $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'); + assertEq('Round-trip test titre ' . $uniq, $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"; // ========================================================================= @@ -172,12 +186,16 @@ try { $langIds = $db->getThesisLanguageIds($thesisId); $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"); $createdLangId = (int)array_values($found)[0]['id']; - assertContains((string)$createdLangId, array_map('strval', $langIds), - 'language_autre ID linked to thesis'); + assertContains( + (string)$createdLangId, + array_map('strval', $langIds), + 'language_autre ID linked to thesis' + ); echo "\n"; // ========================================================================= @@ -197,13 +215,19 @@ try { $createdIds[] = $thesisId; $langIds = $db->getThesisLanguageIds($thesisId); - assertContains((string)$allLangs[0]['id'], array_map('strval', $langIds), - 'checkbox language linked'); + assertContains( + (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']; - assertContains((string)$createdLang2, array_map('strval', $langIds), - 'language_autre also linked'); + assertContains( + (string)$createdLang2, + array_map('strval', $langIds), + 'language_autre also linked' + ); echo "\n"; // ========================================================================= @@ -245,11 +269,14 @@ try { $editCtrl->save($thesisId, $editPost, []); $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"); $createdLang3 = (int)array_values($found3)[0]['id']; - assertContains((string)$createdLang3, array_map('strval', $langIds), - 'language_autre linked on edit'); + assertContains( + (string)$createdLang3, + array_map('strval', $langIds), + 'language_autre linked on edit' + ); echo "\n"; // ========================================================================= @@ -269,11 +296,11 @@ try { $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['cc2r'], 'cc2r saved'); + 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['cc2r'], 'cc2r saved'); echo "\n"; // ========================================================================= @@ -291,11 +318,11 @@ try { $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['cc2r'], 'cc2r cleared'); + 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['cc2r'], 'cc2r cleared'); echo "\n"; // ========================================================================= @@ -338,7 +365,10 @@ try { } finally { // Clean up test theses foreach ($createdIds as $id) { - try { $db->deleteThesis($id); } catch (Exception $e) { /* ignore */ } + try { + $db->deleteThesis($id); + } catch (Exception $e) { /* ignore */ + } } // Clean up test languages $allLangs = $db->getAllLanguages(); @@ -349,7 +379,8 @@ try { || str_starts_with($lang['name'], 'Idempotent_')) { try { $db->getConnection()->prepare('DELETE FROM languages WHERE id = ?')->execute([$lang['id']]); - } catch (Exception $e) { /* ignore */ } + } catch (Exception $e) { /* ignore */ + } } } } diff --git a/app/tests/Unit/PureLogicTest.php b/app/tests/Unit/PureLogicTest.php index 470400c..32a219b 100644 --- a/app/tests/Unit/PureLogicTest.php +++ b/app/tests/Unit/PureLogicTest.php @@ -191,12 +191,12 @@ try { ]; $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'); + 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"; @@ -207,10 +207,10 @@ try { 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['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"; @@ -250,20 +250,20 @@ try { // ── 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'], + ['Titre du TFE', 'titre'], + ["Le champ 'Auteur·ice(s)' est requis.", '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); @@ -312,18 +312,18 @@ try { // ── 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'); + 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'); + 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'); + plAssert(str_contains($slug, 'BOB'), 'contains BOB'); echo "\n"; // ========================================================================= @@ -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 { echo " ✓ no rows to check (empty export) — header count is $headerCount\n"; } @@ -362,7 +362,7 @@ try { $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'); + plAssert(is_array($vars['coverMap']), 'coverMap is an array'); $_GET = []; echo "\n"; diff --git a/app/tests/Unit/ShareLinkTest.php b/app/tests/Unit/ShareLinkTest.php index 2a227c9..16f1fa2 100644 --- a/app/tests/Unit/ShareLinkTest.php +++ b/app/tests/Unit/ShareLinkTest.php @@ -70,8 +70,8 @@ try { $month = (int) substr($slug, 4, 2); $day = (int) substr($slug, 6, 2); slAssert($year >= 2020 && $year <= 2100, 'year in plausible range'); - slAssert($month >= 1 && $month <= 12, 'month in range'); - slAssert($day >= 1 && $day <= 31, 'day in range'); + slAssert($month >= 1 && $month <= 12, 'month in range'); + slAssert($day >= 1 && $day <= 31, 'day in range'); echo "\n"; // ========================================================================= @@ -90,28 +90,29 @@ try { // ========================================================================= 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'); + 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(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(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"; + echo "Test 4: validateLink — link with auto-generated password needs password\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'); + 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'); echo "\n"; // ========================================================================= @@ -120,7 +121,7 @@ try { 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(false, $result['valid'], 'valid=false after disable'); slAssertEq('disabled', $result['reason'], 'reason=disabled'); $model->toggleActive($link['id']); // restore echo "\n"; @@ -133,62 +134,54 @@ try { $createdIds[] = $archivedLink['id']; $model->archive($archivedLink['id']); $result = $model->validateLink($archivedLink['slug']); - slAssertEq(false, $result['valid'], 'valid=false for archived'); + slAssertEq(false, $result['valid'], 'valid=false for archived'); slAssertEq('archived', $result['reason'], 'reason=archived'); 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')); - $expiredLink = $model->create($adminId, null, $pastDate); + $expiredLink = $model->create($adminId, $pastDate); $createdIds[] = $expiredLink['id']; $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'); 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"; - $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); + echo "Test 8: validateLink — needs_password\n"; + $pwLink = $model->create($adminId, null); $createdIds[] = $pwLink['id']; $result = $model->validateLink($pwLink['slug']); - slAssertEq(false, $result['valid'], 'valid=false (needs password)'); + 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 + // 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']); - 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, ''), 'empty password rejected'); + slAssertEq(false, $model->verifyPassword($pwLinkRow, ''), 'empty password rejected'); 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"; - $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 "Test 10: verifyPassword — wrong password rejected\n"; + $anyLinkRow = $model->findBySlug($link['slug']); + slAssertEq(false, $model->verifyPassword($anyLinkRow, ''), 'empty string rejected'); + slAssertEq(false, $model->verifyPassword($anyLinkRow, 'anything'), 'random string rejected'); + slAssertEq(true, $model->verifyPassword($anyLinkRow, $link['_plain_password'] ?? ''), 'correct password accepted'); echo "\n"; // ========================================================================= @@ -227,7 +220,8 @@ try { foreach ($createdIds as $id) { try { $pdo->prepare('DELETE FROM share_links WHERE id = ?')->execute([$id]); - } catch (Exception $e) { /* ignore */ } + } catch (Exception $e) { /* ignore */ + } } } diff --git a/justfile b/justfile index 059bcae..d4db6d4 100644 --- a/justfile +++ b/justfile @@ -349,17 +349,21 @@ lint-biome: @biome lint app/public/assets/js/ [group('test')] -phpstan: +lint-php: + # Static analysis + coding standards check @vendor/bin/phpstan analyse --memory-limit=512M - -[group('test')] -cs-check: @vendor/bin/php-cs-fixer check --no-interaction [group('test')] cs-fix: @vendor/bin/php-cs-fixer fix --no-interaction +[group('test')] +phpstan: lint-php + +[group('test')] +cs-check: lint-php + [group('test')] syntax: @find app/ -name '*.php' -exec php -l {} \; 2>/dev/null | grep -v 'No syntax errors' || true diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2d8aa31..d8a4572 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -6,12 +6,6 @@ parameters: count: 1 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\.$#' identifier: property.onlyWritten @@ -30,6 +24,12 @@ parameters: count: 2 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\.$#' identifier: nullCoalesce.offset