From 3cecee10c9bae311b02677055284d9c383d282d4 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Fri, 10 Jul 2026 16:18:09 +0200 Subject: [PATCH] fix: prevent file deletion on relink + restore button now visible + OOB-style in-place update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two critical fixes: 1. Relink flow no longer destroys/recreates FilePond instances: The relink (XamxamRelinkFile) and PeerTube relink (XamxamRelinkPeerTube) previously refreshed the entire fichiers fragment via HTMX after pond.addFile(). This triggered destroyFilePondsIn on ALL pools, which could fire server.remove callbacks and move existing files to corbeille. Now just closes the modal — the file is already added to the pool in-place, and syncOrderInput creates the hidden form input. 2. Cleanup page « Corbeille (restaurable) » now actually shows files: _cleanup-stats-data.php previously classified trash files by checking if the thesis_files DB row still existed. But both deleteThesisFileToTrash and FilepondHandler::handleRemove DELETE the DB row. So ALL trash files appeared as `stale` (not restorable). Now uses the JSON sidecar file presence as the classification criterion — if the sidecar exists and is recent, the file is restorable regardless of DB row state. Also removed unused DB query from _cleanup-stats-data.php. --- .phpunit.result.cache | 2 +- TODO.md | 6 + .../admin/actions/_cleanup-stats-data.php | 52 +++--- .../admin/actions/cleanup-stats-fragment.php | 61 ++++++- app/public/admin/actions/restore-trash.php | 157 ++++++++++++++++++ .../assets/js/app/file-upload-filepond.js | 66 ++++---- app/src/Controllers/ThesisEditController.php | 52 ++++-- app/src/Controllers/ThesisFileHandler.php | 19 ++- app/src/Database.php | 19 +++ app/src/FilepondHandler.php | 21 +++ tests/phpunit/PureLogicTest.php | 89 ++++++++++ tests/phpunit/ThesisEditValidationTest.php | 116 +++++++++++++ 12 files changed, 587 insertions(+), 73 deletions(-) create mode 100644 app/public/admin/actions/restore-trash.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 0c1ff08..eda39aa 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"CryptoTest::testEncryptDecryptEmptyString":7,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":7,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":7,"SystemControllerHelpersTest::testHumanBytesOneMB":7,"SystemControllerHelpersTest::testHumanBytesOneGB":7,"CryptoTest::testEncryptEmptyStringProducesCiphertext":7,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":7,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":7,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":7,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":7,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":7,"RateLimitExtendedTest::testGetRemainingDecrements":7,"RateLimitExtendedTest::testGetRemainingAtLimit":7,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":7,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":7,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":8,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":8},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0,"CryptoTest::testEncryptDecryptWithUnicode":0,"CryptoTest::testEncryptDecryptMultiline":0,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0,"CryptoTest::testIsEncryptedRejectsPlaintext":0,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0,"EmailObfuscatorTest::testMultipleEmailsInOneString":0,"EmailObfuscatorTest::testEmailWithPlusSign":0,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0,"StudentEmailTest::testBuildHtmlContainsKeyFields":0,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0,"StudentEmailTest::testBuildHtmlContainsLabelFields":0,"SystemControllerHelpersTest::testHumanBytesZero":0,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneMB":0,"SystemControllerHelpersTest::testHumanBytesOneGB":0,"SystemControllerHelpersTest::testHumanBytes1523MB":0,"SystemControllerHelpersTest::testHumanBytes2500GB":0,"SystemControllerHelpersTest::testDiskColorBelowWarning":0,"SystemControllerHelpersTest::testDiskColorWarning":0,"SystemControllerHelpersTest::testDiskColorCritical":0,"SystemControllerHelpersTest::testLogLineClassCrit":0,"SystemControllerHelpersTest::testLogLineClassError":0,"SystemControllerHelpersTest::testLogLineClassWarn":0,"SystemControllerHelpersTest::testLogLineClassNotice":0,"SystemControllerHelpersTest::testLogLineClassHttp500":0,"SystemControllerHelpersTest::testLogLineClassHttp300":0,"SystemControllerHelpersTest::testLogLineClassDefault":0,"SystemControllerHelpersTest::testNginxLineClassComment":0.003,"SystemControllerHelpersTest::testNginxLineClassBlock":0.004,"SystemControllerHelpersTest::testNginxLineClassDirective":0.003,"SystemControllerHelpersTest::testStatusLabelActive":0,"SystemControllerHelpersTest::testStatusLabelInactive":0,"SystemControllerHelpersTest::testStatusLabelFailed":0,"SystemControllerHelpersTest::testStatusLabelWarn":0,"SystemControllerHelpersTest::testStatusLabelUnknown":0,"SystemControllerHelpersTest::testStatusClassOk":0,"SystemControllerHelpersTest::testStatusClassWarn":0,"SystemControllerHelpersTest::testStatusClassError":0,"SystemControllerHelpersTest::testStatusClassUnknown":0,"TfeControllerOgTest::testBuildOgTagsReturnsAllRequiredKeys":0,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0,"DatabaseExtendedTest::testGenerateThesisIdentifierFirstInYear":0,"DatabaseExtendedTest::testGenerateThesisIdentifierIncrementsCorrectly":0,"DatabaseExtendedTest::testGenerateThesisIdentifierUsesMaxNotCount":0,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsPaths":0,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsEmptyForUnknownIds":0,"DatabaseExtendedTest::testGetCoverPathsForThesesEmptyInputReturnsEmpty":0,"DatabaseExtendedTest::testGetCoverPathsForThesesMultipleTheses":0,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":0,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":0,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":0,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":0,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":0,"DatabaseExtendedTest::testRenameLanguageUpdatesName":0.003,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0,"DatabaseExtendedTest::testRenameTagUpdatesName":0,"DatabaseExtendedTest::testMergeTagReassignsTheses":0,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0,"RateLimitExtendedTest::testCheckKeyDoesNotAffectDefaultCheck":0,"RateLimitExtendedTest::testGetRemainingDecrements":0,"RateLimitExtendedTest::testGetRemainingAtLimit":0,"RateLimitExtendedTest::testGetRemainingUsesClientIdentifier":0.001,"RateLimitExtendedTest::testCheckUsesConsistentIdentifier":0,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":0,"RateLimitExtendedTest::testGetResetTimeReturnsZeroWhenNoData":0,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":0,"RateLimitExtendedTest::testCleanupRemovesOldFiles":0,"ShareLinkExtendedTest::testListActiveReturnsOnlyActiveLinks":0.173,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.173,"ShareLinkExtendedTest::testFindBySlugHit":0.172,"ShareLinkExtendedTest::testFindBySlugMiss":0,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.174,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.173,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.173,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.173,"ShareLinkExtendedTest::testCreateWithLockedYear":0.173,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.173,"ShareLinkExtendedTest::testUpdateLockedYear":0.174,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.175,"ShareLinkExtendedTest::testIncrementUsage":0.173,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.173,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.173,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusAuthors":0,"AutofocusFieldForErrorTest::testCreateAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testCreateAutofocusYear":0,"AutofocusFieldForErrorTest::testCreateAutofocusOrientation":0,"AutofocusFieldForErrorTest::testCreateAutofocusAP":0,"AutofocusFieldForErrorTest::testCreateAutofocusFinality":0,"AutofocusFieldForErrorTest::testCreateAutofocusLanguages":0,"AutofocusFieldForErrorTest::testCreateAutofocusPromoteur":0,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurInterne":0,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurExterne":0,"AutofocusFieldForErrorTest::testCreateAutofocusFormats":0,"AutofocusFieldForErrorTest::testCreateAutofocusLicense":0,"AutofocusFieldForErrorTest::testCreateAutofocusUrl":0,"AutofocusFieldForErrorTest::testCreateAutofocusTags":0,"AutofocusFieldForErrorTest::testCreateAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testEditAutofocusTitle":0,"AutofocusFieldForErrorTest::testEditAutofocusYear":0,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0,"ThesisCreateValidationTest::testMissingTitleThrowsException":0,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFinalityInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testInvalidYearFormatRejected":0,"ThesisCreateValidationTest::testYearZeroRejected":0,"ThesisCreateValidationTest::testYearBefore2000Rejected":0,"ThesisCreateValidationTest::testFarFutureYearRejected":0,"ThesisCreateValidationTest::testCurrentYearAccepted":0,"ThesisCreateValidationTest::testMalformedUrlRejected":0,"ThesisCreateValidationTest::testValidUrlAccepted":0,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":0,"ThesisCreateValidationTest::testMaxTenKeywordsEnforced":0,"ThesisCreateValidationTest::testXssPayloadStrippedFromTitle":0,"ThesisCreateValidationTest::testHtmlInSynopsisStripped":0,"ThesisCreateValidationTest::testMultipleAuthorsAreSorted":0,"ThesisCreateValidationTest::testMissingPromoteurInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLecteurInterneInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLanguagesInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFormatsInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLicenseWithLibreAccessThrowsException":0,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":0.001,"ThesisEditValidationTest::testLoadThrowsOnUnknownId":0.001,"ThesisEditValidationTest::testLoadThrowsOnInvalidId":0,"ThesisEditValidationTest::testLoadThrowsOnNegativeId":0,"ThesisEditValidationTest::testCollectJuryMembersEmptyInput":0,"ThesisEditValidationTest::testCollectJuryMembersSinglePromoteur":0,"ThesisEditValidationTest::testCollectJuryMembersPromoteurUlb":0,"ThesisEditValidationTest::testCollectJuryMembersLecteurs":0,"ThesisEditValidationTest::testCollectJuryMembersDeduplicatesEmptyStrings":0,"ThesisEditValidationTest::testCollectJuryMembersScalarPromoteurAccepted":0,"ThesisEditValidationTest::testHandleWebsiteUrlStoresValidUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsInvalidUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsEmptyUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlNormalisesHttp":0,"ErrorHandlerTest::testFkThesesTableMentionsAllPossibleFields":0,"ErrorHandlerTest::testFkApPrograms":0,"ErrorHandlerTest::testFkFinalityTypes":0,"ErrorHandlerTest::testFkThesisLanguages":0,"ErrorHandlerTest::testFkThesisFormats":0,"ErrorHandlerTest::testFkThesisTags":0,"ErrorHandlerTest::testFkThesisSupervisors":0,"ErrorHandlerTest::testFkAccessTypes":0,"ErrorHandlerTest::testFkLicenseTypes":0,"ErrorHandlerTest::testFkAuthors":0,"ErrorHandlerTest::testFkQuotedTableName":0,"ErrorHandlerTest::testFkQuotedLanguages":0,"ErrorHandlerTest::testFkQuotedFormatTypes":0,"ErrorHandlerTest::testFkReferencesTags":0,"ErrorHandlerTest::testFkReferencesOrientations":0,"ErrorHandlerTest::testFkUnknownTableGenericFallback":0,"ErrorHandlerTest::testFkEmptyMessageGenericFallback":0,"ErrorHandlerTest::testUniqueConstraint":0,"ErrorHandlerTest::testNotNullConstraint":0,"ErrorHandlerTest::testGenericPdoError":0,"ErrorHandlerTest::testDuplicateThesisExceptionPassesThrough":0,"ErrorHandlerTest::testValidationExceptionPassesThrough":0,"ErrorHandlerTest::testGenericExceptionPassesThrough":0,"ErrorHandlerTest::testTypeErrorReturnsGeneric":0,"ErrorHandlerTest::testLogWithContext":0,"ErrorHandlerTest::testLogWithNullValues":0,"ErrorHandlerTest::testLogWithEmptyExtra":0,"ErrorHandlerTest::testFkQuotedColumnNames":0,"ErrorHandlerTest::testFkUpdateStatement":0,"ErrorHandlerTest::testFkWithReferencesAndInsert":0,"PureLogicTest::testSplitJuryByRoleAllRoles":0,"PureLogicTest::testSplitJuryByRoleEmptyNameSkipped":0,"PureLogicTest::testSplitJuryByRoleEmptyJury":0,"PureLogicTest::testCollectCaptionPathsVttByMime":0,"PureLogicTest::testCollectCaptionPathsVttByExtension":0,"PureLogicTest::testCollectCaptionPathsNoVttReturnsEmpty":0,"PureLogicTest::testDetectFileTypeByMime":0,"PureLogicTest::testDetectFileTypeByExtensionFallback":0,"SearchControllerTest::testHandleSearchReturnsCoverMapKey":0.002,"SearchControllerTest::testCoverMapContainsKnownThesis":0.001,"DatabaseExtendedTest::testCreateThesisMinimalFields":0.004,"DatabaseExtendedTest::testCreateThesisAllOptionalFields":0,"DatabaseExtendedTest::testCreateThesisGeneratesUniqueIdentifiers":0}} \ No newline at end of file +{"version":2,"defects":{"CryptoTest::testEncryptDecryptEmptyString":7,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":7,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":7,"SystemControllerHelpersTest::testHumanBytesOneMB":7,"SystemControllerHelpersTest::testHumanBytesOneGB":7,"CryptoTest::testEncryptEmptyStringProducesCiphertext":7,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":7,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":7,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":7,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":7,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":7,"RateLimitExtendedTest::testGetRemainingDecrements":7,"RateLimitExtendedTest::testGetRemainingAtLimit":7,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":7,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":7,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":8,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexId":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeVideo":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeAudio":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForMixedIds":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexInCoverQueue":8,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeInCoverQueue":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForNumericIdsOnly":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyInput":8,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyStrings":8,"PureLogicTest::testHasFilePondQueueDataHandlesScalarNotArray":8},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0,"CryptoTest::testEncryptDecryptWithUnicode":0,"CryptoTest::testEncryptDecryptMultiline":0,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0,"CryptoTest::testIsEncryptedRejectsPlaintext":0,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0,"EmailObfuscatorTest::testMultipleEmailsInOneString":0,"EmailObfuscatorTest::testEmailWithPlusSign":0,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0,"StudentEmailTest::testBuildHtmlContainsKeyFields":0,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0,"StudentEmailTest::testBuildHtmlContainsLabelFields":0,"SystemControllerHelpersTest::testHumanBytesZero":0,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneMB":0,"SystemControllerHelpersTest::testHumanBytesOneGB":0,"SystemControllerHelpersTest::testHumanBytes1523MB":0,"SystemControllerHelpersTest::testHumanBytes2500GB":0,"SystemControllerHelpersTest::testDiskColorBelowWarning":0,"SystemControllerHelpersTest::testDiskColorWarning":0,"SystemControllerHelpersTest::testDiskColorCritical":0,"SystemControllerHelpersTest::testLogLineClassCrit":0,"SystemControllerHelpersTest::testLogLineClassError":0,"SystemControllerHelpersTest::testLogLineClassWarn":0,"SystemControllerHelpersTest::testLogLineClassNotice":0,"SystemControllerHelpersTest::testLogLineClassHttp500":0,"SystemControllerHelpersTest::testLogLineClassHttp300":0,"SystemControllerHelpersTest::testLogLineClassDefault":0,"SystemControllerHelpersTest::testNginxLineClassComment":0.003,"SystemControllerHelpersTest::testNginxLineClassBlock":0.004,"SystemControllerHelpersTest::testNginxLineClassDirective":0.003,"SystemControllerHelpersTest::testStatusLabelActive":0,"SystemControllerHelpersTest::testStatusLabelInactive":0,"SystemControllerHelpersTest::testStatusLabelFailed":0,"SystemControllerHelpersTest::testStatusLabelWarn":0,"SystemControllerHelpersTest::testStatusLabelUnknown":0,"SystemControllerHelpersTest::testStatusClassOk":0,"SystemControllerHelpersTest::testStatusClassWarn":0,"SystemControllerHelpersTest::testStatusClassError":0,"SystemControllerHelpersTest::testStatusClassUnknown":0,"TfeControllerOgTest::testBuildOgTagsReturnsAllRequiredKeys":0,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0,"DatabaseExtendedTest::testGenerateThesisIdentifierFirstInYear":0,"DatabaseExtendedTest::testGenerateThesisIdentifierIncrementsCorrectly":0,"DatabaseExtendedTest::testGenerateThesisIdentifierUsesMaxNotCount":0,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsPaths":0,"DatabaseExtendedTest::testGetCoverPathsForThesesReturnsEmptyForUnknownIds":0,"DatabaseExtendedTest::testGetCoverPathsForThesesEmptyInputReturnsEmpty":0,"DatabaseExtendedTest::testGetCoverPathsForThesesMultipleTheses":0,"DatabaseExtendedTest::testFindOrCreateAuthorCreatesNew":0,"DatabaseExtendedTest::testFindOrCreateAuthorIdempotent":0,"DatabaseExtendedTest::testFindOrCreateAuthorWithEmail":0,"DatabaseExtendedTest::testFindOrCreateAuthorRejectsCSVArtefacts":0,"DatabaseExtendedTest::testDeduplicateLanguagesMergesCaseInsensitiveDupes":0,"DatabaseExtendedTest::testRenameLanguageUpdatesName":0.003,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0,"DatabaseExtendedTest::testRenameTagUpdatesName":0,"DatabaseExtendedTest::testMergeTagReassignsTheses":0,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0,"RateLimitExtendedTest::testCheckKeyDoesNotAffectDefaultCheck":0,"RateLimitExtendedTest::testGetRemainingDecrements":0,"RateLimitExtendedTest::testGetRemainingAtLimit":0,"RateLimitExtendedTest::testGetRemainingUsesClientIdentifier":0.001,"RateLimitExtendedTest::testCheckUsesConsistentIdentifier":0,"RateLimitExtendedTest::testGetRemainingReturnsZeroAfterExhaustion":0,"RateLimitExtendedTest::testGetResetTimeReturnsZeroWhenNoData":0,"RateLimitExtendedTest::testGetResetTimePositiveAfterHits":0,"RateLimitExtendedTest::testCleanupRemovesOldFiles":0,"ShareLinkExtendedTest::testListActiveReturnsOnlyActiveLinks":0.173,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.173,"ShareLinkExtendedTest::testFindBySlugHit":0.172,"ShareLinkExtendedTest::testFindBySlugMiss":0,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.174,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.173,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.173,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.173,"ShareLinkExtendedTest::testCreateWithLockedYear":0.173,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.173,"ShareLinkExtendedTest::testUpdateLockedYear":0.174,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.175,"ShareLinkExtendedTest::testIncrementUsage":0.173,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.173,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.173,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":0.002,"AutofocusFieldForErrorTest::testCreateAutofocusAuthors":0,"AutofocusFieldForErrorTest::testCreateAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testCreateAutofocusYear":0,"AutofocusFieldForErrorTest::testCreateAutofocusOrientation":0,"AutofocusFieldForErrorTest::testCreateAutofocusAP":0,"AutofocusFieldForErrorTest::testCreateAutofocusFinality":0,"AutofocusFieldForErrorTest::testCreateAutofocusLanguages":0,"AutofocusFieldForErrorTest::testCreateAutofocusPromoteur":0,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurInterne":0,"AutofocusFieldForErrorTest::testCreateAutofocusLecteurExterne":0,"AutofocusFieldForErrorTest::testCreateAutofocusFormats":0,"AutofocusFieldForErrorTest::testCreateAutofocusLicense":0,"AutofocusFieldForErrorTest::testCreateAutofocusUrl":0,"AutofocusFieldForErrorTest::testCreateAutofocusTags":0,"AutofocusFieldForErrorTest::testCreateAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testEditAutofocusTitle":0,"AutofocusFieldForErrorTest::testEditAutofocusYear":0,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0,"ThesisCreateValidationTest::testMissingTitleThrowsException":0,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFinalityInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testInvalidYearFormatRejected":0,"ThesisCreateValidationTest::testYearZeroRejected":0,"ThesisCreateValidationTest::testYearBefore2000Rejected":0,"ThesisCreateValidationTest::testFarFutureYearRejected":0,"ThesisCreateValidationTest::testCurrentYearAccepted":0,"ThesisCreateValidationTest::testMalformedUrlRejected":0,"ThesisCreateValidationTest::testValidUrlAccepted":0,"ThesisCreateValidationTest::testDuplicateTagsAreDeduplicated":0,"ThesisCreateValidationTest::testMaxTenKeywordsEnforced":0,"ThesisCreateValidationTest::testXssPayloadStrippedFromTitle":0,"ThesisCreateValidationTest::testHtmlInSynopsisStripped":0,"ThesisCreateValidationTest::testMultipleAuthorsAreSorted":0,"ThesisCreateValidationTest::testMissingPromoteurInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLecteurInterneInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLanguagesInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingFormatsInNonAdminModeThrowsException":0,"ThesisCreateValidationTest::testMissingLicenseWithLibreAccessThrowsException":0,"ThesisEditValidationTest::testLoadReturnsDataForKnownId":0.002,"ThesisEditValidationTest::testLoadThrowsOnUnknownId":0,"ThesisEditValidationTest::testLoadThrowsOnInvalidId":0,"ThesisEditValidationTest::testLoadThrowsOnNegativeId":0,"ThesisEditValidationTest::testCollectJuryMembersEmptyInput":0,"ThesisEditValidationTest::testCollectJuryMembersSinglePromoteur":0,"ThesisEditValidationTest::testCollectJuryMembersPromoteurUlb":0,"ThesisEditValidationTest::testCollectJuryMembersLecteurs":0,"ThesisEditValidationTest::testCollectJuryMembersDeduplicatesEmptyStrings":0,"ThesisEditValidationTest::testCollectJuryMembersScalarPromoteurAccepted":0,"ThesisEditValidationTest::testHandleWebsiteUrlStoresValidUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsInvalidUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlSkipsEmptyUrl":0,"ThesisEditValidationTest::testHandleWebsiteUrlNormalisesHttp":0,"ErrorHandlerTest::testFkThesesTableMentionsAllPossibleFields":0,"ErrorHandlerTest::testFkApPrograms":0,"ErrorHandlerTest::testFkFinalityTypes":0,"ErrorHandlerTest::testFkThesisLanguages":0,"ErrorHandlerTest::testFkThesisFormats":0,"ErrorHandlerTest::testFkThesisTags":0,"ErrorHandlerTest::testFkThesisSupervisors":0,"ErrorHandlerTest::testFkAccessTypes":0,"ErrorHandlerTest::testFkLicenseTypes":0,"ErrorHandlerTest::testFkAuthors":0,"ErrorHandlerTest::testFkQuotedTableName":0,"ErrorHandlerTest::testFkQuotedLanguages":0,"ErrorHandlerTest::testFkQuotedFormatTypes":0,"ErrorHandlerTest::testFkReferencesTags":0,"ErrorHandlerTest::testFkReferencesOrientations":0,"ErrorHandlerTest::testFkUnknownTableGenericFallback":0,"ErrorHandlerTest::testFkEmptyMessageGenericFallback":0,"ErrorHandlerTest::testUniqueConstraint":0,"ErrorHandlerTest::testNotNullConstraint":0,"ErrorHandlerTest::testGenericPdoError":0,"ErrorHandlerTest::testDuplicateThesisExceptionPassesThrough":0,"ErrorHandlerTest::testValidationExceptionPassesThrough":0,"ErrorHandlerTest::testGenericExceptionPassesThrough":0,"ErrorHandlerTest::testTypeErrorReturnsGeneric":0,"ErrorHandlerTest::testLogWithContext":0,"ErrorHandlerTest::testLogWithNullValues":0,"ErrorHandlerTest::testLogWithEmptyExtra":0,"ErrorHandlerTest::testFkQuotedColumnNames":0,"ErrorHandlerTest::testFkUpdateStatement":0,"ErrorHandlerTest::testFkWithReferencesAndInsert":0,"PureLogicTest::testSplitJuryByRoleAllRoles":0.004,"PureLogicTest::testSplitJuryByRoleEmptyNameSkipped":0,"PureLogicTest::testSplitJuryByRoleEmptyJury":0,"PureLogicTest::testCollectCaptionPathsVttByMime":0,"PureLogicTest::testCollectCaptionPathsVttByExtension":0,"PureLogicTest::testCollectCaptionPathsNoVttReturnsEmpty":0,"PureLogicTest::testDetectFileTypeByMime":0.001,"PureLogicTest::testDetectFileTypeByExtensionFallback":0,"SearchControllerTest::testHandleSearchReturnsCoverMapKey":0.002,"SearchControllerTest::testCoverMapContainsKnownThesis":0.001,"DatabaseExtendedTest::testCreateThesisMinimalFields":0.004,"DatabaseExtendedTest::testCreateThesisAllOptionalFields":0,"DatabaseExtendedTest::testCreateThesisGeneratesUniqueIdentifiers":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexId":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeVideo":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeAudio":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForMixedIds":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForHexInCoverQueue":0,"PureLogicTest::testHasFilePondQueueDataReturnsTrueForPeertubeInCoverQueue":0,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForNumericIdsOnly":0,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyInput":0,"PureLogicTest::testHasFilePondQueueDataReturnsFalseForEmptyStrings":0,"PureLogicTest::testHasFilePondQueueDataHandlesScalarNotArray":0,"ThesisEditValidationTest::testHandleWebsiteUrlPreservesExistingRowWhenUrlUnchanged":0,"ThesisEditValidationTest::testHandleWebsiteUrlPreservesLabelWhenNotProvided":0,"ThesisEditValidationTest::testHandleWebsiteUrlUpdatesLabelWhenProvided":0,"ThesisEditValidationTest::testHandleWebsiteUrlDeletesRowWhenUrlExplicitlyCleared":0.002,"ThesisEditValidationTest::testHandleWebsiteUrlUpdatesUrlWhenChanged":0,"ThesisEditValidationTest::testHandleWebsiteUrlNoExistingRowEmptyUrlDoesNothing":0}} \ No newline at end of file diff --git a/TODO.md b/TODO.md index eb6ba24..c050c09 100644 --- a/TODO.md +++ b/TODO.md @@ -7,3 +7,9 @@ - [x] Fix PeerTube upload failure: PHP /tmp tmpfs too small for large video files - [x] Fix keyword word-breaking in repertoire.php: widen kw grid column, add white-space: nowrap - [x] Fix licence.php TOC: restore `open` attribute on `
` (lost in vrxsstns, never restored by uuomvtvm fix) +- [x] Fix hasFilePondQueueData() missing peertube: prefix check → new PeerTube uploads silently lost on edit +- [x] Fix handleWebsiteUrl() in ThesisEditController unconditionally deleting website rows on every edit +- [x] Fix FilePond server.remove potentially triggering during HTMX fragment teardown +- [x] Add restore-from-corbeille functionality in cleanup page +- [x] Fix cleanup stats: use sidecar JSON for restorability classification (not DB row existence) +- [x] Remove destructive HTMX fragment refresh from relink flow (close modal only, pond.addFile in-place) diff --git a/app/public/admin/actions/_cleanup-stats-data.php b/app/public/admin/actions/_cleanup-stats-data.php index 288eb3c..ebd00ab 100644 --- a/app/public/admin/actions/_cleanup-stats-data.php +++ b/app/public/admin/actions/_cleanup-stats-data.php @@ -120,17 +120,12 @@ function getCleanupStats(): array $db = new Database(); $pdo = $db->getPDO(); - $existingFileIds = []; - $stmt = $pdo->query('SELECT id FROM thesis_files'); - while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { - $existingFileIds[(int)$row['id']] = true; - } - $trStaleCount = 0; $trStaleSize = 0; $trStaleFiles = []; $trActiveCount = 0; $trActiveSize = 0; + $trActiveFiles = []; if (is_dir($trashDir)) { $items = @scandir($trashDir); @@ -139,6 +134,10 @@ function getCleanupStats(): array if ($item === '.' || $item === '..') { continue; } + // Skip JSON sidecar files — they're listed alongside their parent. + if (str_ends_with($item, '.json')) { + continue; + } $filePath = $trashDir . '/' . $item; if (!is_file($filePath)) { continue; @@ -148,20 +147,37 @@ function getCleanupStats(): array $mtime = filemtime($filePath); $ageDays = (int)(($now - $mtime) / 86400); - $stale = false; - - if (preg_match('/^(\d+)_/', $item, $m)) { - $dbId = (int)$m[1]; - if (!isset($existingFileIds[$dbId])) { - $stale = true; + // Check for sidecar metadata — files with sidecars are restorable + // regardless of whether the thesis_files DB row still exists. + $sidecarPath = $filePath . '.json'; + $hasSidecar = file_exists($sidecarPath); + $sidecarData = null; + if ($hasSidecar) { + $sidecarData = json_decode(file_get_contents($sidecarPath), true); + if (!is_array($sidecarData)) { + $hasSidecar = false; + $sidecarData = null; } } - if (!$stale && $ageDays > ($maxAgeTrash / 86400)) { - $stale = true; - } + // Restorable = has a valid sidecar AND is younger than max age + $restorable = $hasSidecar && $ageDays <= ($maxAgeTrash / 86400); - if ($stale) { + if ($restorable) { + $trActiveCount++; + $trActiveSize += $size; + $trActiveFiles[] = [ + 'name' => $item, + 'size' => $size, + 'human' => humanBytes($size), + 'age_days' => $ageDays, + 'has_sidecar' => true, + 'thesis_id' => $sidecarData['thesis_id'] ?? null, + 'file_type' => $sidecarData['file_type'] ?? '?', + 'original_name' => $sidecarData['file_name'] ?? '', + 'original_path' => $sidecarData['file_path'] ?? '', + ]; + } else { $trStaleCount++; $trStaleSize += $size; $trStaleFiles[] = [ @@ -170,9 +186,6 @@ function getCleanupStats(): array 'human' => humanBytes($size), 'age_days' => $ageDays, ]; - } else { - $trActiveCount++; - $trActiveSize += $size; } } } @@ -193,5 +206,6 @@ function getCleanupStats(): array 'trash_active_count' => $trActiveCount, 'trash_active_size' => $trActiveSize, 'trash_active_human' => humanBytes($trActiveSize), + 'trash_active_files' => $trActiveFiles, ]; } diff --git a/app/public/admin/actions/cleanup-stats-fragment.php b/app/public/admin/actions/cleanup-stats-fragment.php index 597d07f..d8462b5 100644 --- a/app/public/admin/actions/cleanup-stats-fragment.php +++ b/app/public/admin/actions/cleanup-stats-fragment.php @@ -92,7 +92,7 @@ if ($trStale > 0) { 0): ?> -

Corbeille

+

Corbeille (à nettoyer)

@@ -120,9 +120,62 @@ if ($trStale > 0) {
NomTailleÂge
- 0 || $trActive > 0): ?> + 1 ? 's' : ''); + if ($trActive > 0) { + $trActiveMeta .= ' · ' . ($d['trash_active_human'] ?? ''); + } +?> +

Corbeille (restaurable)

+

+ Fichiers récemment supprimés pour lesquels le TFE associé existe encore. Vous pouvez les restaurer ou les supprimer définitivement. +

+ + + + + + + + + + + + + +
NomTailleÂgeOrigine
~ j + + + + + + +
+ + + 0): ?>

Conservés : - - +

diff --git a/app/public/admin/actions/restore-trash.php b/app/public/admin/actions/restore-trash.php new file mode 100644 index 0000000..2cb0777 --- /dev/null +++ b/app/public/admin/actions/restore-trash.php @@ -0,0 +1,157 @@ + false, 'error' => 'Méthode non autorisée.']); + exit; +} + +if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) + || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { + http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'CSRF invalide.']); + exit; +} + +$trashFile = trim($_POST['trash_file'] ?? ''); +if ($trashFile === '') { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Nom de fichier invalide.']); + exit; +} + +$storageRoot = STORAGE_ROOT; +$trashDir = $storageRoot . '/tmp/_trash'; +$trashPath = $trashDir . '/' . basename($trashFile); +$sidecarPath = $trashPath . '.json'; + +// Validate the trash file is inside the trash directory +if (!is_file($trashPath) || !str_starts_with(realpath($trashPath), realpath($trashDir))) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Fichier introuvable dans la corbeille.']); + exit; +} + +// Read sidecar metadata +if (!file_exists($sidecarPath)) { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => false, + 'error' => 'Métadonnées de restauration absentes (fichier trop ancien ?). Utilisez "Relier un fichier existant".', + ]); + exit; +} + +$sidecar = json_decode(file_get_contents($sidecarPath), true); +if (!is_array($sidecar) || empty($sidecar['file_path']) || empty($sidecar['thesis_id'])) { + http_response_code(400); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Métadonnées corrompues.']); + exit; +} + +$originalPath = $sidecar['file_path']; +$thesisId = (int)$sidecar['thesis_id']; +$fileType = $sidecar['file_type'] ?? 'other'; +$fileName = $sidecar['file_name'] ?? basename($originalPath); +$mimeType = $sidecar['mime_type'] ?? 'application/octet-stream'; +$fileSize = (int)($sidecar['file_size'] ?? 0); +$displayLabel = $sidecar['display_label'] ?? null; + +// Verify the thesis still exists +require_once __DIR__ . '/../../../src/Database.php'; +$db = new Database(); +$thesis = $db->getThesis($thesisId); +if (!$thesis) { + http_response_code(404); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Le TFE associé n\'existe plus.']); + exit; +} + +// Check no file already exists at the original path +$absOriginal = $storageRoot . '/' . $originalPath; +if (file_exists($absOriginal)) { + // File already restored or a new file replaced it — just clean up the trash + @unlink($trashPath); + @unlink($sidecarPath); + http_response_code(409); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => false, + 'error' => 'Un fichier existe déjà à l\'emplacement d\'origine. Fichier corbeille nettoyé.', + ]); + exit; +} + +// Ensure parent directory exists +$parentDir = dirname($absOriginal); +if (!is_dir($parentDir)) { + mkdir($parentDir, 0755, true); +} + +// Move file back from trash to original location +if (!rename($trashPath, $absOriginal)) { + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'Échec du déplacement du fichier.']); + exit; +} +chmod($absOriginal, 0644); + +// Delete the sidecar file +@unlink($sidecarPath); + +// Re-insert the thesis_files DB row +$db->insertThesisFile( + $thesisId, + $fileType, + $originalPath, + $fileName, + $fileSize, + $mimeType, + $displayLabel, + null +); + +$newId = $db->getConnection()->lastInsertId(); + +error_log("[restore-trash] thesis_id=$thesisId file_id=$newId restored from trash: $trashFile → $originalPath"); + +$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + +// HTMX request: re-render the fragment +if (isset($_SERVER['HTTP_HX_REQUEST'])) { + header('HX-Trigger: refreshStats'); + require __DIR__ . '/cleanup-stats-fragment.php'; + exit; +} + +header('Content-Type: application/json; charset=utf-8'); +echo json_encode([ + 'ok' => true, + 'id' => $newId, + 'thesis_id' => $thesisId, + 'message' => 'Fichier restauré avec succès.', +]); +exit; diff --git a/app/public/assets/js/app/file-upload-filepond.js b/app/public/assets/js/app/file-upload-filepond.js index c07ec6d..6f93087 100644 --- a/app/public/assets/js/app/file-upload-filepond.js +++ b/app/public/assets/js/app/file-upload-filepond.js @@ -321,6 +321,15 @@ remove: (source, load, error) => { console.log(`[filepond] remove called | id=${source}`); + // During teardown (HTMX swap), skip server round-trips. + // FilePond 4.x destroy() does not normally fire remove callbacks, + // but this guard prevents accidental deletion of DB files as a + // defence-in-depth measure. + if (_xamxamTeardown) { + console.log(`[filepond] remove skipped (teardown) | id=${source}`); + load(); + return; + } // Hex IDs (32 chars) → temp files → use revert endpoint if (/^[a-f0-9]{32}$/.test(source)) { fetch(`${base}/revert.php`, { @@ -519,6 +528,13 @@ }); }; + /** + * Guard flag set during destroyFilePondsIn — the server.remove callback + * checks this and skips the server round-trip during teardown to prevent + * accidental deletion of existing DB files from FilePond.destroy(). + */ + let _xamxamTeardown = false; + /** * Destroy FilePond instances inside a given container element. * Generic: handles ANY HTMX swap target, not just known IDs. @@ -563,8 +579,12 @@ } catch (_abort) {} } } + _xamxamTeardown = true; pond.destroy(); - } catch (_) {} + _xamxamTeardown = false; + } catch (_) { + _xamxamTeardown = false; + } } }); } @@ -885,24 +905,9 @@ " | found=" + !!input, ); - var closeAndRefresh = () => { + var closeModal = () => { var modal = document.getElementById("relink-modal"); if (modal) modal.close(); - // Re-fetch the fichiers fragment from the server so the - // newly-linked file appears in the FilePond pools. - var block = document.getElementById("format-fichiers-block"); - if (block && window.htmx) { - let url = "/admin/fragments/fichiers.php"; - if (window.__xamxamRelinkCtx?.thesisId) { - url += - "?_thesis_id=" + - encodeURIComponent(window.__xamxamRelinkCtx.thesisId); - } - htmx.ajax("GET", url, { - target: "#format-fichiers-block", - swap: "outerHTML", - }); - } }; if (input) { const pond = FilePond.find(input); @@ -924,25 +929,25 @@ " | queueType=" + queueType, ); - closeAndRefresh(); + closeModal(); }) .catch((err) => { console.error("[relink] addFile rejected", err); - closeAndRefresh(); + closeModal(); }); } else { console.error( "[relink] FilePond.find returned null for input", input, ); - closeAndRefresh(); + closeModal(); } } else { console.warn( "[relink] input not found, page may have reloaded | queueType=" + queueType, ); - closeAndRefresh(); + closeModal(); } // Mark form dirty @@ -1008,18 +1013,9 @@ var input = document.querySelector( '.tfe-file-picker[data-queue-type="tfe"]', ); - var closeAndRefresh = () => { + var closeModal = () => { var modal = document.getElementById("peertube-relink-modal"); if (modal) modal.close(); - var block = document.getElementById("format-fichiers-block"); - if (block && window.htmx) { - var url = "/admin/fragments/fichiers.php"; - if (thesisId) url += `?_thesis_id=${encodeURIComponent(thesisId)}`; - htmx.ajax("GET", url, { - target: "#format-fichiers-block", - swap: "outerHTML", - }); - } }; if (input) { var pond = FilePond.find(input); @@ -1035,19 +1031,19 @@ }) .then(() => { console.log("[pt-relink] addFile resolved"); - closeAndRefresh(); + closeModal(); }) .catch((err) => { console.error("[pt-relink] addFile rejected", err); - closeAndRefresh(); + closeModal(); }); } else { console.error("[pt-relink] FilePond.find returned null"); - closeAndRefresh(); + closeModal(); } } else { console.warn("[pt-relink] input not found"); - closeAndRefresh(); + closeModal(); } window.__xamxamDirty = true; diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 8ee814b..3175b31 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -627,15 +627,25 @@ class ThesisEditController { $websiteUrl = trim($post['website_url'] ?? ''); - // Remove existing website rows (website URLs have no disk file) + // Find existing website row (if any) $existingFiles = $this->db->getThesisFiles($thesisId); + $existingWebsiteRow = null; foreach ($existingFiles as $f) { if ($f['file_type'] === 'website') { - $this->db->deleteThesisFile((int)$f['id'], $thesisId); + $existingWebsiteRow = $f; + break; } } + // No URL provided and no existing row → nothing to do. + if ($websiteUrl === '' && $existingWebsiteRow === null) { + return; + } + + // URL explicitly cleared → delete existing website row. if ($websiteUrl === '') { + $this->db->deleteThesisFile((int)$existingWebsiteRow['id'], $thesisId); + error_log('ThesisEditController: website removed (explicitly cleared)'); return; } @@ -650,16 +660,32 @@ class ThesisEditController $sortOrder = isset($post['website_order']) ? (int)$post['website_order'] : null; $fileName = rtrim(preg_replace('#^https?://#i', '', $websiteUrl), '/'); - $this->db->insertThesisFile( - $thesisId, - 'website', - $websiteUrl, - $fileName, - 0, - 'text/html', - $label !== '' ? $label : null, - $sortOrder - ); - error_log("ThesisEditController: website stored → $websiteUrl"); + if ($existingWebsiteRow !== null) { + // Preserve existing label if no new label is provided. + if ($label === '' && !empty($existingWebsiteRow['display_label'])) { + $label = $existingWebsiteRow['display_label']; + } + // Update existing row in-place instead of delete + re-insert. + $this->db->updateThesisWebsiteUrl( + (int)$existingWebsiteRow['id'], + $thesisId, + $websiteUrl, + $fileName, + $label !== '' ? $label : null + ); + error_log("ThesisEditController: website updated → $websiteUrl"); + } else { + $this->db->insertThesisFile( + $thesisId, + 'website', + $websiteUrl, + $fileName, + 0, + 'text/html', + $label !== '' ? $label : null, + $sortOrder + ); + error_log("ThesisEditController: website stored → $websiteUrl"); + } } } diff --git a/app/src/Controllers/ThesisFileHandler.php b/app/src/Controllers/ThesisFileHandler.php index 3ce6bd3..349377a 100644 --- a/app/src/Controllers/ThesisFileHandler.php +++ b/app/src/Controllers/ThesisFileHandler.php @@ -872,7 +872,9 @@ trait ThesisFileHandler $ids = is_array($raw) ? $raw : [$raw]; foreach ($ids as $id) { $id = is_string($id) ? trim($id) : ''; - if ($id !== '' && preg_match('/^[a-f0-9]{32}$/', $id)) { + // 32-char hex IDs = regular FilePond async uploads. + // peertube: prefix = video/audio uploaded to PeerTube via process.php. + if ($id !== '' && (preg_match('/^[a-f0-9]{32}$/', $id) || str_starts_with($id, 'peertube:'))) { return true; } } @@ -1299,6 +1301,21 @@ trait ThesisFileHandler @copy($abs, $trashPath); @unlink($abs); } + // Save metadata sidecar for potential restore + $sidecar = [ + 'thesis_id' => $thesisId, + 'file_id' => $fileId, + 'file_type' => $fileRow['file_type'] ?? 'other', + 'file_path' => $filePath, + 'file_name' => $fileRow['file_name'] ?? basename($filePath), + 'mime_type' => $fileRow['mime_type'] ?? 'application/octet-stream', + 'file_size' => $fileRow['file_size'] ?? 0, + 'deleted_at' => date('c'), + ]; + @file_put_contents( + $trashPath . '.json', + json_encode($sidecar, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); error_log("ThesisFileHandler: file {$fileId} moved to trash → {$trashName}"); } } diff --git a/app/src/Database.php b/app/src/Database.php index ebdc1fa..593f5c6 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -2565,6 +2565,25 @@ class Database )->execute([$label ?: null, $fileId, $thesisId]); } + /** + * Update an existing website-type thesis_files row in-place. + * + * Avoids the delete-then-insert pattern that loses the row ID and + * any associated metadata. Only updates the URL, derived filename, + * and optional display label. + */ + public function updateThesisWebsiteUrl( + int $fileId, + int $thesisId, + string $url, + string $fileName, + ?string $label + ): void { + $this->pdo->prepare( + 'UPDATE thesis_files SET file_path = ?, file_name = ?, display_label = ? WHERE id = ? AND thesis_id = ?' + )->execute([$url, $fileName, $label ?: null, $fileId, $thesisId]); + } + /** * Delete a single thesis file record by its ID and optionally remove the * file from disk. Returns the file_path that was deleted (or null if not diff --git a/app/src/FilepondHandler.php b/app/src/FilepondHandler.php index 2f11d2b..e795928 100644 --- a/app/src/FilepondHandler.php +++ b/app/src/FilepondHandler.php @@ -332,6 +332,12 @@ class FilepondHandler } $filePath = $fileRow['file_path'] ?? ''; + $thesisId = $fileRow['thesis_id'] ?? 0; + $fileType = $fileRow['file_type'] ?? 'other'; + $fileName = $fileRow['file_name'] ?? basename($filePath); + $mimeType = $fileRow['mime_type'] ?? 'application/octet-stream'; + $fileSize = $fileRow['file_size'] ?? 0; + if ($filePath !== '' && !str_starts_with($filePath, 'peertube_ids:') && !str_starts_with($filePath, 'http://') @@ -345,6 +351,21 @@ class FilepondHandler } $trashPath = $trashDir . '/' . $dbId . '_' . basename($filePath); rename($absPath, $trashPath); + // Save metadata sidecar for potential restore + $sidecar = [ + 'thesis_id' => (int)$thesisId, + 'file_id' => $dbId, + 'file_type' => $fileType, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'mime_type' => $mimeType, + 'file_size' => (int)$fileSize, + 'deleted_at' => date('c'), + ]; + @file_put_contents( + $trashPath . '.json', + json_encode($sidecar, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ); } } diff --git a/tests/phpunit/PureLogicTest.php b/tests/phpunit/PureLogicTest.php index 7916f9f..b98616e 100644 --- a/tests/phpunit/PureLogicTest.php +++ b/tests/phpunit/PureLogicTest.php @@ -38,6 +38,17 @@ class PureLogicTest extends TestCase }; } + /** + * Invoke the private hasFilePondQueueData() method via reflection. + * The method lives in the ThesisFileHandler trait, used by ThesisCreateController. + */ + private function invokeHasFilePondQueueData(array $post): bool + { + $ctrl = $this->getThesisCreateController(); + $ref = new ReflectionMethod(ThesisCreateController::class, 'hasFilePondQueueData'); + return $ref->invoke($ctrl, $post); + } + // ── splitJuryByRole ────────────────────────────────────────────────────── public function testSplitJuryByRoleAllRoles(): void @@ -150,4 +161,82 @@ class PureLogicTest extends TestCase $this->assertSame('image', $ctrl->exposedDetectFileType('application/octet-stream', 'webp')); $this->assertSame('caption', $ctrl->exposedDetectFileType('application/octet-stream', 'vtt')); } + + // ── hasFilePondQueueData ──────────────────────────────────────────────── + + public function testHasFilePondQueueDataReturnsTrueForHexId(): void + { + $post = ['queue_file' => ['tfe' => ['abc123def456abc123def456abc123de']]]; + $this->assertTrue($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataReturnsTrueForPeertubeVideo(): void + { + $post = ['queue_file' => ['tfe' => ['peertube:video:bmpQZTUPv4ou8ufiwajV63']]]; + $this->assertTrue( + $this->invokeHasFilePondQueueData($post), + 'peertube:video:UUID should be detected as FilePond data' + ); + } + + public function testHasFilePondQueueDataReturnsTrueForPeertubeAudio(): void + { + $post = ['queue_file' => ['tfe' => ['peertube:audio:xyz123']]]; + $this->assertTrue( + $this->invokeHasFilePondQueueData($post), + 'peertube:audio:UUID should be detected as FilePond data' + ); + } + + public function testHasFilePondQueueDataReturnsTrueForMixedIds(): void + { + $post = ['queue_file' => ['tfe' => ['123', 'peertube:video:abc123', '456']]]; + $this->assertTrue( + $this->invokeHasFilePondQueueData($post), + 'Mixed array containing a peertube: ID should be detected' + ); + } + + public function testHasFilePondQueueDataReturnsTrueForHexInCoverQueue(): void + { + $post = ['queue_file' => ['cover' => ['abcdef1234567890abcdef1234567890']]]; + $this->assertTrue($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataReturnsTrueForPeertubeInCoverQueue(): void + { + $post = ['queue_file' => ['cover' => ['peertube:video:uuid1']]]; + $this->assertTrue($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataReturnsFalseForNumericIdsOnly(): void + { + $post = ['queue_file' => ['tfe' => ['123', '456']]]; + $this->assertFalse($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataReturnsFalseForEmptyInput(): void + { + $this->assertFalse($this->invokeHasFilePondQueueData([])); + $this->assertFalse($this->invokeHasFilePondQueueData(['queue_file' => []])); + $this->assertFalse($this->invokeHasFilePondQueueData(['queue_file' => ['tfe' => []]])); + } + + public function testHasFilePondQueueDataReturnsFalseForEmptyStrings(): void + { + $post = ['queue_file' => ['tfe' => ['', ' ']]]; + $this->assertFalse($this->invokeHasFilePondQueueData($post)); + } + + public function testHasFilePondQueueDataHandlesScalarNotArray(): void + { + $post = ['queue_file' => ['tfe' => 'peertube:video:singleUuid']]; + $this->assertTrue($this->invokeHasFilePondQueueData($post)); + + $post2 = ['queue_file' => ['tfe' => 'abc123def456abc123def456abc123de']]; + $this->assertTrue($this->invokeHasFilePondQueueData($post2)); + + $post3 = ['queue_file' => ['tfe' => '123']]; + $this->assertFalse($this->invokeHasFilePondQueueData($post3)); + } } diff --git a/tests/phpunit/ThesisEditValidationTest.php b/tests/phpunit/ThesisEditValidationTest.php index 844bc9c..e28abb2 100644 --- a/tests/phpunit/ThesisEditValidationTest.php +++ b/tests/phpunit/ThesisEditValidationTest.php @@ -193,4 +193,120 @@ class ThesisEditValidationTest extends TestCase $file = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); $this->assertStringContainsString('example.com/path', $file['file_name']); } + + // ── handleWebsiteUrl regression: existing rows preserved (not deleted-then-recreated) ─ + + public function testHandleWebsiteUrlPreservesExistingRowWhenUrlUnchanged(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Keep Site', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + // Seed an existing website row + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://old.example.com', 'old.example.com', 0, 'text/html', 'Old Label')" + )->execute([$thesisId]); + $oldId = (int)$pdo->lastInsertId(); + + // Submit the SAME URL (no change intended) + $post = ['website_url' => 'https://old.example.com']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + // The row should still exist with the same ID and (crucially) preserved label + $row = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); + $this->assertNotFalse($row, 'Website row should still exist'); + $this->assertSame($oldId, (int)$row['id'], 'Row ID should be preserved (not delete+reinsert)'); + $this->assertSame('Old Label', $row['display_label'], 'Label should be preserved when no new label is given'); + $this->assertSame('https://old.example.com', $row['file_path']); + } + + public function testHandleWebsiteUrlPreservesLabelWhenNotProvided(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Label Preserve', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://example.com', 'example.com', 0, 'text/html', 'My Custom Label')" + )->execute([$thesisId]); + + // Submit URL without a label + $post = ['website_url' => 'https://example.com', 'website_label' => '']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $row = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); + $this->assertSame('My Custom Label', $row['display_label'], 'Existing label should survive when no new label is provided'); + } + + public function testHandleWebsiteUrlUpdatesLabelWhenProvided(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Label Update', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://example.com', 'example.com', 0, 'text/html', 'Old Label')" + )->execute([$thesisId]); + + $post = ['website_url' => 'https://example.com', 'website_label' => 'New Label']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $row = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); + $this->assertSame('New Label', $row['display_label']); + } + + public function testHandleWebsiteUrlDeletesRowWhenUrlExplicitlyCleared(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Clear Site', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://example.com', 'example.com', 0, 'text/html', 'Label')" + )->execute([$thesisId]); + + // Explicitly clear the URL + $post = ['website_url' => '']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $count = $pdo->query("SELECT COUNT(*) FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetchColumn(); + $this->assertSame(0, (int)$count, 'Website row should be deleted when URL is explicitly cleared'); + } + + public function testHandleWebsiteUrlUpdatesUrlWhenChanged(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Change URL', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, display_label) + VALUES (?, 'website', 'https://old.example.com', 'old.example.com', 0, 'text/html', 'Label')" + )->execute([$thesisId]); + $oldId = (int)$pdo->lastInsertId(); + + $post = ['website_url' => 'https://new.example.com']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $row = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch(); + $this->assertSame($oldId, (int)$row['id'], 'Row ID should be preserved on URL update'); + $this->assertSame('https://new.example.com', $row['file_path']); + $this->assertSame('Label', $row['display_label'], 'Label should be preserved on URL-only change'); + } + + public function testHandleWebsiteUrlNoExistingRowEmptyUrlDoesNothing(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Noop', 'Author', 2024); + $pdo = TestDatabase::getPDO(); + + // Should not error even with no existing row + $post = ['website_url' => '']; + $this->invokeHandleWebsiteUrl($thesisId, $post); + + $count = $pdo->query("SELECT COUNT(*) FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetchColumn(); + $this->assertSame(0, (int)$count); + + // All other files should still be intact + $totalFiles = $pdo->query("SELECT COUNT(*) FROM thesis_files WHERE thesis_id = $thesisId")->fetchColumn(); + $this->assertGreaterThan(0, (int)$totalFiles, 'Cover file from seeding should still exist'); + } }