diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 4faa9de..6c9b48b 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},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0.001,"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.001,"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,"SystemControllerHelpersTest::testNginxLineClassBlock":0,"SystemControllerHelpersTest::testNginxLineClassDirective":0,"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.001,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0.001,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0.001,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0,"DatabaseExtendedTest::testEscapeLikeStringViaSearchConditions":0,"DatabaseExtendedTest::testBuildSearchConditionsEmptyParams":0.001,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0.001,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0.002,"DatabaseExtendedTest::testGenerateThesisIdentifierFirstInYear":0.001,"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.001,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0,"DatabaseExtendedTest::testRenameTagUpdatesName":0,"DatabaseExtendedTest::testMergeTagReassignsTheses":0,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0.001,"RateLimitExtendedTest::testCheckKeyDoesNotAffectDefaultCheck":0.001,"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.298,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.291,"ShareLinkExtendedTest::testFindBySlugHit":0.289,"ShareLinkExtendedTest::testFindBySlugMiss":0,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.29,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.29,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.293,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.296,"ShareLinkExtendedTest::testCreateWithLockedYear":0.311,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.289,"ShareLinkExtendedTest::testUpdateLockedYear":0.29,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.289,"ShareLinkExtendedTest::testIncrementUsage":0.297,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.292,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.293,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":0.005,"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.001,"AutofocusFieldForErrorTest::testEditAutofocusYear":0,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0,"ThesisCreateValidationTest::testMissingTitleThrowsException":0.003,"ThesisCreateValidationTest::testMissingAuthorsThrowsException":0,"ThesisCreateValidationTest::testMissingSynopsisThrowsException":0,"ThesisCreateValidationTest::testMissingOrientationInNonAdminModeThrowsException":0.001,"ThesisCreateValidationTest::testMissingAPProgramInNonAdminModeThrowsException":0.001,"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.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.002}} \ 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},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0.001,"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.001,"StudentEmailTest::testBuildHtmlContainsKeyFields":0,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0,"StudentEmailTest::testBuildHtmlContainsLabelFields":0,"SystemControllerHelpersTest::testHumanBytesZero":0.001,"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,"SystemControllerHelpersTest::testNginxLineClassBlock":0,"SystemControllerHelpersTest::testNginxLineClassDirective":0,"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.001,"DatabaseExtendedTest::testBuildSearchConditionsWithQuery":0,"DatabaseExtendedTest::testBuildSearchConditionsWithYear":0.001,"DatabaseExtendedTest::testBuildSearchConditionsWithAllFilters":0,"DatabaseExtendedTest::testFindDuplicateThesisExactMatch":0.001,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentTitle":0,"DatabaseExtendedTest::testFindDuplicateThesisMissesDifferentYear":0,"DatabaseExtendedTest::testFindDuplicateThesisEmptyAuthorNamesReturnsNull":0.001,"DatabaseExtendedTest::testFindDuplicateThesisEmptyTable":0.001,"DatabaseExtendedTest::testFindDuplicateThesisNearDuplicateByLevenshtein":0.001,"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.001,"DatabaseExtendedTest::testMergeLanguageReassignsTheses":0,"DatabaseExtendedTest::testRenameTagUpdatesName":0,"DatabaseExtendedTest::testMergeTagReassignsTheses":0,"RateLimitExtendedTest::testCheckKeyCountsPerKey":0.001,"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.299,"ShareLinkExtendedTest::testListArchivedReturnsOnlyArchivedLinks":0.304,"ShareLinkExtendedTest::testFindBySlugHit":0.306,"ShareLinkExtendedTest::testFindBySlugMiss":0,"ShareLinkExtendedTest::testSetPasswordAndDecryptRoundTrip":0.306,"ShareLinkExtendedTest::testGetDecryptedPasswordOnNonexistentId":0,"ShareLinkExtendedTest::testUpdateChangesNameAndExpiration":0.314,"ShareLinkExtendedTest::testUpdateOnlyNameLeavesExpirationUnchanged":0.301,"ShareLinkExtendedTest::testUpdateClearsExpiration":0.292,"ShareLinkExtendedTest::testCreateWithLockedYear":0.302,"ShareLinkExtendedTest::testCreateWithInvalidLockedYearRejected":0.291,"ShareLinkExtendedTest::testUpdateLockedYear":0.295,"ShareLinkExtendedTest::testUpdateClearLockedYear":0.291,"ShareLinkExtendedTest::testIncrementUsage":0.291,"ShareLinkExtendedTest::testCreateDefaultsToTfeWhenInvalidObjet":0.299,"ShareLinkExtendedTest::testCreateAcceptsValidObjet":0.296,"RateLimitExtendedTest::testGetRemainingStartsAtMax":0,"RateLimitExtendedTest::testCheckDecrementsRemainingForSameIp":0,"RateLimitExtendedTest::testCheckAndCheckKeyAreIndependent":0,"RateLimitExtendedTest::testMultipleChecksFromSameClient":0,"AutofocusFieldForErrorTest::testCreateAutofocusTitle":0.007,"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.001,"AutofocusFieldForErrorTest::testEditAutofocusYear":0,"AutofocusFieldForErrorTest::testEditAutofocusSynopsis":0,"AutofocusFieldForErrorTest::testEditAutofocusAuthors":0,"AutofocusFieldForErrorTest::testEditAutofocusUnknownErrorReturnsNull":0,"AutofocusFieldForErrorTest::testCreateDoesNotLeakEditFieldNames":0,"ThesisCreateValidationTest::testValidSubmissionReturnsCleanedData":0,"ThesisCreateValidationTest::testMissingTitleThrowsException":0.003,"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.001,"ThesisCreateValidationTest::testValidUrlAccepted":0.002,"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.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.001,"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.003,"SearchControllerTest::testCoverMapContainsKnownThesis":0.002}} \ No newline at end of file diff --git a/TODO.md b/TODO.md index 253cc87..dda90b8 100644 --- a/TODO.md +++ b/TODO.md @@ -25,11 +25,11 @@ - [x] 3.3 `AutofocusFieldForErrorTest.php` — correct field per error key, unknown key returns null/default, no CreateController name leak ## Phase 4 — Cleanup -- [ ] 4.1 Migrate 8 existing custom-runner tests to PHPUnit in `tests/phpunit/` -- [ ] 4.2 Verify all pass under `vendor/bin/phpunit` -- [ ] 4.3 Remove `run-tests.php` and old test files -- [ ] 4.4 Add `vendor/bin/phpunit` to justfile/Makefile CI target -- [ ] 4.5 Generate baseline coverage report (`--coverage-html coverage/`) +- [x] 4.1 Migrate 8 existing custom-runner tests to PHPUnit in `tests/phpunit/` +- [x] 4.2 Verify all pass under `vendor/bin/phpunit` +- [x] 4.3 Remove `run-tests.php` and old test files +- [x] 4.4 Add `vendor/bin/phpunit` to justfile/Makefile CI target +- [ ] 4.5 Generate baseline coverage report (`--coverage-html coverage/`) — needs Xdebug/PCov - [ ] 4.6 Commit coverage baseline --- diff --git a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json index 17c4fd6..85fa2e0 100644 --- a/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json +++ b/app/storage/cache/rate_limit/ad921d60486366258809553a3db49a4a.json @@ -1 +1 @@ -[1779232892] \ No newline at end of file +[1779234737] \ No newline at end of file diff --git a/app/tests/Integration/SearchTest.php b/app/tests/Integration/SearchTest.php deleted file mode 100644 index 8dadc42..0000000 --- a/app/tests/Integration/SearchTest.php +++ /dev/null @@ -1,85 +0,0 @@ -searchTheses([]); - if (is_array($results)) { - echo '✓ PASS: Empty query handled (returned ' . count($results) . " results)\n\n"; - } else { - throw new Exception('Invalid results for empty query'); - } - - // Test 2: Search for specific term - echo "Test 2: Search for Specific Term\n"; - $searchTerm = 'art'; // Common word likely to appear - $results = $db->searchTheses(['query' => $searchTerm]); - if (is_array($results)) { - echo "✓ PASS: Search for '$searchTerm' returned " . count($results) . " results\n\n"; - } else { - throw new Exception('Invalid search results'); - } - - // Test 3: Search with special characters - echo "Test 3: Search with Special Characters\n"; - $results = $db->searchTheses(['query' => "test's \"quotes\" & symbols"]); - if (is_array($results)) { - echo "✓ PASS: Special characters handled safely\n\n"; - } else { - throw new Exception('Failed to handle special characters'); - } - - // Test 4: Tag-filter search using the new EXISTS subquery - echo "Test 4: Tag-filter search (thesis_tags subquery)\n"; - $tagResults = $db->searchTheses(['keyword' => 'urbanisme']); - if (is_array($tagResults)) { - echo "✓ PASS: Tag search for 'urbanisme' returned " . count($tagResults) . " result(s)\n"; - foreach ($tagResults as $r) { - echo ' - ' . $r['title'] . ' (' . $r['year'] . ")\n"; - } - echo "\n"; - } else { - throw new Exception('Tag search returned non-array'); - } - - // Test 5: Tag search in full-text query (query touches tag subquery) - echo "Test 5: Full-text query includes tag subquery\n"; - $allResults = $db->searchTheses(['query' => 'narration']); - if (is_array($allResults)) { - echo "✓ PASS: Query 'narration' returned " . count($allResults) . " result(s)\n\n"; - } else { - throw new Exception('Full-text query with tag subquery failed'); - } - - // Test 6: countSearchResults matches searchTheses - echo "Test 6: countSearchResults consistency\n"; - $params = ['keyword' => 'urbanisme']; - $count = $db->countSearchResults($params); - $rows = $db->searchTheses($params, 100); - if ($count === count($rows)) { - echo "✓ PASS: count=$count matches row count\n\n"; - } else { - throw new Exception("countSearchResults ($count) != searchTheses row count (" . count($rows) . ')'); - } - - echo "✅ All search tests passed!\n"; - return true; - -} catch (Exception $e) { - echo '❌ FAIL: ' . $e->getMessage() . "\n"; - return false; -} diff --git a/app/tests/README.md b/app/tests/README.md deleted file mode 100644 index d7d3628..0000000 --- a/app/tests/README.md +++ /dev/null @@ -1,231 +0,0 @@ -# XAMXAM Test Suite - -Centralized test suite for the XAMXAM thesis management system. - -## 📁 Structure - -``` -tests/ -├── run-tests.php # Test runner (runs all tests) -├── Unit/ # Unit tests -│ ├── DatabaseTest.php # Database connection & queries -│ └── RateLimitTest.php # Rate limiting functionality -├── Integration/ # Integration tests -│ └── SearchTest.php # Search functionality -├── Security/ # Security tests -│ └── SecurityTest.php # SQL injection & XSS protection -└── README.md # This file -``` - -## 🚀 Running Tests - -### Run All Tests - -```bash -# Using justfile (recommended) -just test - -# Or directly -php tests/run-tests.php -``` - -### Run Individual Tests - -```bash -# Database test -php tests/Unit/DatabaseTest.php - -# Search test -php tests/Integration/SearchTest.php - -# Security test -php tests/Security/SecurityTest.php - -# Rate limit test -php tests/Unit/RateLimitTest.php -``` - -## ✅ Test Coverage - -### Unit Tests - -**DatabaseTest.php** - Tests basic database operations: -- ✅ Database connection -- ✅ Count published theses -- ✅ Get published theses -- ✅ Get single thesis by ID - -**RateLimitTest.php** - Tests rate limiting: -- ✅ RateLimit initialization -- ✅ check() method -- ✅ sendHeaders() method -- ✅ getResetTime() method -- ✅ cleanup() method - -### Integration Tests - -**SearchTest.php** - Tests search functionality: -- ✅ Empty search query handling -- ✅ Search for specific terms -- ✅ Special characters in search - -### Security Tests - -**SecurityTest.php** - Tests security measures: -- ✅ SQL injection protection -- ✅ Invalid ID rejection -- ✅ XSS protection (output escaping) - -## 📝 Writing New Tests - -### Test File Template - -```php -getMessage() . "\n"; - return false; -} -``` - -### Guidelines - -1. **Return Value**: Return `true` for pass, `false` for fail -2. **Output Format**: Use `✓ PASS:` for successes, `❌ FAIL:` for failures -3. **Exceptions**: Catch and report exceptions clearly -4. **Dependencies**: Require only what's needed via relative paths -5. **Location**: - - `Unit/` - Tests for individual classes/functions - - `Integration/` - Tests for feature workflows - - `Security/` - Tests for security vulnerabilities - -## 🔧 Test Database - -Tests use the main database at `storage/xamxam.db`. - -### Setup Test Database - -```bash -# Create from schema -just init-db -``` - -### Reset Test Database - -```bash -just reset-db -``` - -## 📊 Expected Output - -Successful test run: -``` -╔════════════════════════════════════════════╗ -║ XAMXAM Test Suite ║ -╚════════════════════════════════════════════╝ - -┌─────────────────────────────────────────┐ -│ Database (Unit) │ -└─────────────────────────────────────────┘ - -✓ PASS: Database connection successful -✓ PASS: Found 16 published theses -... -✅ TEST PASSED - -... - -╔════════════════════════════════════════════╗ -║ Test Summary ║ -╠════════════════════════════════════════════╣ -║ Total: 4 ║ -║ Passed: 4 ✅ ║ -║ Failed: 0 ║ -╚════════════════════════════════════════════╝ - -✅ All tests passed! -``` - -## 🐛 Debugging Failed Tests - -### Check Logs - -```bash -# Application errors -tail -f error.log - -# Test output -php tests/run-tests.php > test-output.txt 2>&1 -``` - -### Run Tests Individually - -When a test fails, run it directly to see full output: - -```bash -php tests/Unit/DatabaseTest.php -``` - -### Check Database - -```bash -# Open database -just query - -# Check stats -just stats -``` - -## 🔄 Continuous Testing - -### Watch Mode (Future) - -Could add file watching for auto-run: - -```bash -# Future: auto-run tests on file change -just watch-tests -``` - -### Pre-commit Hook (Future) - -Add to `.git/hooks/pre-commit`: - -```bash -#!/bin/bash -php tests/run-tests.php -``` - -## 📚 Related Documentation - -- [Database Specification](../storage/DATABASE_SPECIFICATION.md) -- [Security Documentation](../docs/SECURITY.md) -- [Development Guide](../MIGRATION_GUIDE.md) - ---- - -**To run tests:** `just test` diff --git a/app/tests/Security/SecurityTest.php b/app/tests/Security/SecurityTest.php deleted file mode 100644 index 81699bd..0000000 --- a/app/tests/Security/SecurityTest.php +++ /dev/null @@ -1,75 +0,0 @@ - $string] to exercise the actual parameterised - // query path rather than triggering a PHP TypeError before any SQL runs. - echo "Test 1: SQL Injection Protection (Search)\n"; - $maliciousQueries = [ - "' OR '1'='1", - "'; DROP TABLE theses; --", - "1' UNION SELECT * FROM authors--", - "", - ]; - - foreach ($maliciousQueries as $query) { - try { - $results = $db->searchTheses(['query' => $query]); - // Should return a (possibly empty) result set without throwing - echo ' ✓ Handled safely: ' . substr($query, 0, 40) . "\n"; - } catch (Exception $e) { - // A thrown exception is also acceptable (query rejected upstream) - echo ' ✓ Exception (safe): ' . substr($query, 0, 40) . "\n"; - } - } - echo "✓ PASS: SQL injection attempts handled safely\n\n"; - - // Test 2: Invalid thesis ID - echo "Test 2: Invalid Thesis ID\n"; - $invalidIds = ['abc', "'; DROP TABLE theses;", '-1', '999999']; - - foreach ($invalidIds as $id) { - $result = $db->getThesisById($id); - if ($result === null || $result === false) { - echo ' ✓ Rejected: ' . $id . "\n"; - } else { - throw new Exception("Invalid ID '$id' was not rejected"); - } - } - echo "✓ PASS: Invalid IDs rejected\n\n"; - - // Test 3: XSS in output (checking data is escaped) - echo "Test 3: XSS Protection (Output Escaping)\n"; - $theses = $db->getPublishedTheses(1, 0); - if (count($theses) > 0) { - $first = $theses[0]; - // Check that HTML special chars would be handled - if (isset($first['title'])) { - echo " ✓ Title data retrieved safely\n"; - } - } - echo "✓ PASS: Output handling verified\n\n"; - - echo "✅ All security tests passed!\n"; - return true; - -} catch (Exception $e) { - echo '❌ FAIL: ' . $e->getMessage() . "\n"; - return false; -} diff --git a/app/tests/Unit/DatabaseTest.php b/app/tests/Unit/DatabaseTest.php deleted file mode 100644 index 6ff6d3d..0000000 --- a/app/tests/Unit/DatabaseTest.php +++ /dev/null @@ -1,84 +0,0 @@ -countPublishedTheses(); - if ($count >= 0) { - echo "✓ PASS: Found {$count} published theses\n\n"; - } else { - throw new Exception('Invalid count returned'); - } - - // Test 3: Get published theses - echo "Test 3: Get Published Theses\n"; - $theses = $db->getPublishedTheses(5, 0); - if (is_array($theses)) { - echo '✓ PASS: Retrieved ' . count($theses) . " theses\n\n"; - } else { - throw new Exception('Invalid theses array returned'); - } - - // Test 4: Get single thesis (if any exist) - if (count($theses) > 0) { - echo "Test 4: Get Single Thesis\n"; - $first = $theses[0]; - $thesis = $db->getThesisById($first['id']); - - if ($thesis && isset($thesis['id'])) { - echo "✓ PASS: Successfully retrieved thesis #{$first['id']}\n"; - echo ' Title: ' . $thesis['title'] . "\n"; - echo ' Author(s): ' . ($thesis['authors'] ?? 'N/A') . "\n"; - echo ' Year: ' . $thesis['year'] . "\n\n"; - } else { - throw new Exception('Failed to retrieve thesis by ID'); - } - } - - // Test 5: findOrCreateTag round-trip - echo "Test 5: findOrCreateTag round-trip\n"; - $testTag = '__test_tag_' . bin2hex(random_bytes(4)); - $id1 = $db->findOrCreateTag($testTag); - $id2 = $db->findOrCreateTag($testTag); // should return same id - if ($id1 && $id1 === $id2) { - echo "✓ PASS: findOrCreateTag returned consistent id=$id1 for '$testTag'\n\n"; - // Clean up - $db->deleteTag($id1); - } else { - throw new Exception("findOrCreateTag round-trip failed: id1=$id1, id2=$id2"); - } - - // Test 6: getUsedTags returns array with 'name' column - echo "Test 6: getUsedTags returns name column\n"; - $tags = $db->getUsedTags(); - if (is_array($tags) && (empty($tags) || isset($tags[0]['name']))) { - echo '✓ PASS: getUsedTags returned ' . count($tags) . " tags with 'name' column\n\n"; - } else { - throw new Exception('getUsedTags did not return expected structure: ' . json_encode($tags[0] ?? [])); - } - - echo "✅ All database tests passed!\n"; - return true; - -} catch (Exception $e) { - echo '❌ FAIL: ' . $e->getMessage() . "\n"; - return false; -} diff --git a/app/tests/Unit/ErrorHandlerTest.php b/app/tests/Unit/ErrorHandlerTest.php deleted file mode 100644 index 12da185..0000000 --- a/app/tests/Unit/ErrorHandlerTest.php +++ /dev/null @@ -1,452 +0,0 @@ - 42, - 'slug' => '20250101-TEST1234', - 'author' => 'Test Author', - ]); - echo " ✓ log() completed without exception\n"; - } catch (Throwable $e) { - throw new RuntimeException('FAIL: log() threw: ' . $e->getMessage()); - } - - echo "J2: log with null values in extra\n"; - ErrorHandler::log('test_null', new PDOException('test'), ['id' => null, 'name' => 'foo']); - echo " ✓ log() handles null values\n"; - - echo "J3: log with empty extra array\n"; - ErrorHandler::log('test_empty', new RuntimeException('bare')); - echo " ✓ log() handles empty extra\n"; - - echo "\n"; - - // ========================================================================= - // SECTION K: Keyword normalization logic (tag normalization) - // ========================================================================= - - echo "K: Keyword normalization logic\n"; - - // 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))); - - echo "K1: basic trimming and casing\n"; - ehAssertEq('hello', $normalize('Hello'), 'uppercase → lowercase'); - ehAssertEq('hello', $normalize(' hello '), 'trimmed'); - ehAssertEq('hello world', $normalize('Hello World'), 'two words lowercased'); - - echo "K2: multiple spaces collapsed\n"; - ehAssertEq('a b c', $normalize('a b c'), 'double spaces → single'); - ehAssertEq('x y', $normalize(' x y '), 'leading/trailing + multiple → clean'); - - echo "K3: tabs and newlines collapsed to space\n"; - ehAssertEq('word1 word2', $normalize("word1\tword2"), 'tab → space'); - ehAssertEq('line1 line2', $normalize("line1\nline2"), 'newline → space'); - ehAssertEq('mixed spaces', $normalize("mixed \t \n spaces"), 'mixed whitespace → single space'); - - echo "K4: French accents preserved\n"; - ehAssertEq('très précis', $normalize('Très Précis'), 'accents preserved in lowercase'); - - echo "K5: empty string\n"; - ehAssertEq('', $normalize(''), 'empty stays empty'); - 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'); - - echo "\n"; - - // ========================================================================= - // SECTION L: Deduplication on normalize (case-insensitive) - // ========================================================================= - - echo "L: Deduplication after normalization\n"; - - $dedup = function (array $tags): array { - return array_values(array_unique(array_map( - fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))), - $tags - ))); - }; - - echo "L1: case-insensitive dedup\n"; - ehAssertEq(['hello'], $dedup(['Hello', 'hello', 'HELLO']), 'case variations → one entry'); - - echo "L2: whitespace-insensitive dedup\n"; - 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 !== '')); - 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 !== '')); - ehAssertEq(['alpha', 'beta'], $result, 'deduplicated and empties filtered'); - - echo "\n"; - - // ========================================================================= - // SECTION M: Minimum/maximum tag count enforcement\n - // ========================================================================= - - echo "M: Tag count constraints\n"; - - echo "M1: 3 tags is valid\n"; - $valid = ['one', 'two', 'three']; - ehAssert(count($valid) >= 3, '3 tags ≥ minimum 3'); - ehAssert(count($valid) <= 10, '3 tags ≤ maximum 10'); - - echo "M2: < 3 tags triggers error\n"; - $tooFew = ['one']; - ehAssert(count($tooFew) < 3, '1 tag < minimum 3'); - - echo "M3: > 10 tags triggers error\n"; - $tooMany = ['a','b','c','d','e','f','g','h','i','j','k']; - ehAssert(count($tooMany) > 10, '11 tags > maximum 10'); - - echo "M4: empty array\n"; - ehAssert(count([]) < 3, 'empty array < minimum 3'); - - echo "\n"; - - // ========================================================================= - // SECTION N: Real SQLite FK error message formats - // ========================================================================= - - echo "N: Real-world SQLite FK error message patterns\n"; - - // These are actual error messages observed in the wild. - echo "N1: typical INSERT INTO with VALUES\n"; - $msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats ("thesis_id", "format_id") VALUES (?, ?)'); - $user = ErrorHandler::userMessage($msg); - ehAssertContains('Format(s)', $user, 'quoted column names handled'); - - echo "N2: UPDATE statement\n"; - $msg = makeFkException('FOREIGN KEY constraint failed UPDATE theses SET orientation_id = ? WHERE id = ?'); - $user = ErrorHandler::userMessage($msg); - ehAssertContains('Orientation', $user, 'UPDATE statement parsed'); - - echo "N3: long FK message with multiple table references\n"; - // Only the first match should be used (the INSERT target table) - $msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?) REFERENCES format_types'); - $user = ErrorHandler::userMessage($msg); - ehAssertContains('Format(s)', $user, 'first table match used'); - - echo "\n"; - - echo "✅ All ErrorHandler tests passed!\n"; - $result = true; - -} catch (Exception $e) { - echo '❌ FAIL: ' . $e->getMessage() . "\n"; - $result = false; -} - -return $result ?? false; diff --git a/app/tests/Unit/FormSaveTest.php b/app/tests/Unit/FormSaveTest.php deleted file mode 100644 index 6c78bef..0000000 --- a/app/tests/Unit/FormSaveTest.php +++ /dev/null @@ -1,388 +0,0 @@ -getAllOrientations(); - $apPrograms = $db->getAllAPPrograms(); - $finalityTypes = $db->getAllFinalityTypes(); - $languages = $db->getAllLanguages(); - $formatTypes = $db->getAllFormatTypes(); - $licenseTypes = $db->getAllLicenseTypes(); - - if (empty($orientations) || empty($apPrograms) || empty($finalityTypes) - || empty($languages) || empty($formatTypes) || empty($licenseTypes)) { - throw new RuntimeException('Lookup tables empty — cannot build POST fixture'); - } - - $base = [ - 'titre' => 'Test TFE Title', - 'subtitle' => 'Test Subtitle', - 'auteurice' => 'Doe, Jane', - 'mail' => 'jane@example.com', - 'synopsis' => 'A short synopsis for testing purposes.', - 'année' => '2025', - 'orientation' => (string)$orientations[0]['id'], - 'ap' => (string)$apPrograms[0]['id'], - 'finality' => (string)$finalityTypes[0]['id'], - 'has_annexes' => '', - 'languages' => [(string)$languages[0]['id']], - 'language_autre' => '', - 'formats' => [(string)$formatTypes[0]['id']], - 'tag' => 'art, test, recherche', - 'jury_promoteur' => 'Prof. Smith', - 'jury_lecteur_interne' => ['Dr. Internal'], - 'jury_lecteur_externe' => ['Dr. External'], - 'license_id' => (string)$licenseTypes[0]['id'], - 'license_custom' => '', - 'access_type_id' => '2', - 'objet' => 'tfe', - 'lien' => '', - 'context_note' => '', - 'remarks' => '', - 'jury_points' => '', - 'exemplaire_baiu' => '', - 'exemplaire_erg' => '', - 'cc2r' => '', - 'is_published' => '', - ]; - - return array_merge($base, $overrides); -} - -/** - * Assert helper — throws on failure, echoes on pass. - */ -function assertEq(mixed $expected, mixed $actual, string $label): void -{ - if ($expected == $actual) { - echo " ✓ $label\n"; - } else { - $e = var_export($expected, true); - $a = var_export($actual, true); - throw new RuntimeException("FAIL $label\n expected: $e\n actual: $a"); - } -} - -function assertContains(mixed $needle, array $haystack, string $label): void -{ - if (in_array($needle, $haystack, false)) { - echo " ✓ $label\n"; - } else { - $a = implode(', ', $haystack); - throw new RuntimeException("FAIL $label: $needle not in [$a]"); - } -} - -function assertNotEmpty(mixed $value, string $label): void -{ - if (!empty($value)) { - echo " ✓ $label\n"; - } else { - throw new RuntimeException("FAIL $label: value is empty"); - } -} - -// ── Test setup ──────────────────────────────────────────────────────────────── - -echo "Form Save Round-Trip Test\n"; -echo "=========================\n\n"; - -$db = Database::getInstance(); -$createCtrl = new ThesisCreateController($db); -$editCtrl = new ThesisEditController($db); - -// 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 { - - // ========================================================================= - // 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 ' . $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 ' . $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"; - - // ========================================================================= - // TEST 2: Create — language_autre creates and links new language - // ========================================================================= - echo "Test 2: Create — language_autre creates and links new language\n"; - $uniqueLang = 'TestLang_' . bin2hex(random_bytes(4)); - $post = buildPost($db, [ - 'titre' => 'Language autre test', - 'languages' => [], // no checkbox - 'language_autre' => $uniqueLang, - ]); - - $thesisId = $createCtrl->submit($post, []); - $createdIds[] = $thesisId; - - $langIds = $db->getThesisLanguageIds($thesisId); - $allLangs = $db->getAllLanguages(); - $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' - ); - echo "\n"; - - // ========================================================================= - // TEST 3: Create — language_autre + checkbox together - // ========================================================================= - echo "Test 5: Create — language_autre appended alongside checked languages\n"; - $db2 = Database::getInstance(); - $allLangs = $db2->getAllLanguages(); - $uniqueLang2 = 'TestLang2_' . bin2hex(random_bytes(4)); - $post = buildPost($db, [ - 'titre' => 'Language combo test', - 'languages' => [(string)$allLangs[0]['id']], - 'language_autre' => $uniqueLang2, - ]); - - $thesisId = $createCtrl->submit($post, []); - $createdIds[] = $thesisId; - $langIds = $db->getThesisLanguageIds($thesisId); - - assertContains( - (string)$allLangs[0]['id'], - array_map('strval', $langIds), - 'checkbox language linked' - ); - - $found2 = array_filter($db->getAllLanguages(), fn ($l) => strtolower($l['name']) === strtolower($uniqueLang2)); - $createdLang2 = (int)array_values($found2)[0]['id']; - assertContains( - (string)$createdLang2, - array_map('strval', $langIds), - 'language_autre also linked' - ); - echo "\n"; - - // ========================================================================= - // TEST 4: Edit — language checkboxes round-trip - // ========================================================================= - echo "Test 3: Edit — language checkboxes round-trip\n"; - $allLangs = $db->getAllLanguages(); - $lang1 = (string)$allLangs[0]['id']; - $lang2 = (string)$allLangs[1]['id']; - - $post = buildPost($db, [ - 'titre' => 'Lang checkbox test', - 'languages' => [$lang1], - ]); - $thesisId = $createCtrl->submit($post, []); - $createdIds[] = $thesisId; - - $editPost = buildPost($db, [ - 'titre' => 'Lang checkbox test', - 'languages' => [$lang1, $lang2], - ]); - $editCtrl->save($thesisId, $editPost, []); - $langIds = $db->getThesisLanguageIds($thesisId); - - assertContains($lang1, array_map('strval', $langIds), 'first language retained on edit'); - assertContains($lang2, array_map('strval', $langIds), 'second language added on edit'); - echo "\n"; - - // ========================================================================= - // TEST 5: Edit — language_autre adds new language - // ========================================================================= - echo "Test 4: Edit — language_autre creates and links on edit\n"; - $uniqueLang3 = 'EditLang_' . bin2hex(random_bytes(4)); - $editPost = buildPost($db, [ - 'titre' => 'Lang checkbox test', - 'languages' => [$lang1], - 'language_autre' => $uniqueLang3, - ]); - $editCtrl->save($thesisId, $editPost, []); - - $langIds = $db->getThesisLanguageIds($thesisId); - $found3 = array_filter($db->getAllLanguages(), fn ($l) => 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' - ); - echo "\n"; - - // ========================================================================= - // TEST 6: Create — backoffice fields persisted - // ========================================================================= - echo "Test 5: Create — backoffice fields (remarks, jury_points, exemplaires, cc2r)\n"; - $post = buildPost($db, [ - 'titre' => 'Backoffice fields test', - 'remarks' => 'Internal note here', - 'jury_points' => '15.5', - 'exemplaire_baiu' => '1', - 'exemplaire_erg' => '1', - 'cc2r' => '1', - ]); - - $thesisId = $createCtrl->submit($post, []); - $createdIds[] = $thesisId; - $raw = $db->getThesisRawFields($thesisId); - - assertEq('Internal note here', $raw['remarks'], 'remarks saved'); - assertEq(15.5, (float)$raw['jury_points'], 'jury_points saved'); - assertEq(1, (int)$raw['exemplaire_baiu'], 'exemplaire_baiu saved'); - assertEq(1, (int)$raw['exemplaire_erg'], 'exemplaire_erg saved'); - assertEq(1, (int)$raw['cc2r'], 'cc2r saved'); - echo "\n"; - - // ========================================================================= - // TEST 7: Edit — backoffice fields updated - // ========================================================================= - echo "Test 6: Edit — backoffice fields updated\n"; - $editPost = buildPost($db, [ - 'titre' => 'Backoffice fields test', - 'remarks' => 'Updated note', - 'jury_points' => '18', - 'exemplaire_baiu' => '', - 'exemplaire_erg' => '1', - 'cc2r' => '', - ]); - $editCtrl->save($thesisId, $editPost, []); - $raw = $db->getThesisRawFields($thesisId); - - assertEq('Updated note', $raw['remarks'], 'remarks updated'); - assertEq(18.0, (float)$raw['jury_points'], 'jury_points updated'); - assertEq(0, (int)$raw['exemplaire_baiu'], 'exemplaire_baiu cleared'); - assertEq(1, (int)$raw['exemplaire_erg'], 'exemplaire_erg retained'); - assertEq(0, (int)$raw['cc2r'], 'cc2r cleared'); - echo "\n"; - - // ========================================================================= - // TEST 8: getOrCreateLanguage — idempotent - // ========================================================================= - echo "Test 7: getOrCreateLanguage — idempotent (same name returns same ID)\n"; - $uniqueName = 'Idempotent_' . bin2hex(random_bytes(4)); - $id1 = $db->getOrCreateLanguage($uniqueName); - $id2 = $db->getOrCreateLanguage($uniqueName); - $id3 = $db->getOrCreateLanguage(strtolower($uniqueName)); // case-insensitive - - assertEq($id1, $id2, 'same ID on second call'); - assertEq($id1, $id3, 'same ID with different case'); - echo "\n"; - - // ========================================================================= - // TEST 9: Edit — context_note saved - // ========================================================================= - echo "Test 8: Edit — context_note saved\n"; - $post = buildPost($db, ['titre' => 'Context note test']); - $thesisId = $createCtrl->submit($post, []); - $createdIds[] = $thesisId; - - $editPost = buildPost($db, [ - 'titre' => 'Context note test', - 'context_note' => 'A contextual note visible publicly.', - ]); - $editCtrl->save($thesisId, $editPost, []); - $raw = $db->getThesisRawFields($thesisId); - - assertEq('A contextual note visible publicly.', $raw['context_note'], 'context_note saved'); - echo "\n"; - - echo "✅ All form save tests passed!\n"; - $result = true; - -} catch (Exception $e) { - echo '❌ FAIL: ' . $e->getMessage() . "\n"; - $result = false; -} finally { - // Clean up test theses - foreach ($createdIds as $id) { - try { - $db->deleteThesis($id); - } catch (Exception $e) { /* ignore */ - } - } - // Clean up test languages - $allLangs = $db->getAllLanguages(); - foreach ($allLangs as $lang) { - if (str_starts_with($lang['name'], 'TestLang_') - || str_starts_with($lang['name'], 'TestLang2_') - || str_starts_with($lang['name'], 'EditLang_') - || str_starts_with($lang['name'], 'Idempotent_')) { - try { - $db->getConnection()->prepare('DELETE FROM languages WHERE id = ?')->execute([$lang['id']]); - } catch (Exception $e) { /* ignore */ - } - } - } -} - -return $result ?? false; diff --git a/app/tests/Unit/PureLogicTest.php b/app/tests/Unit/PureLogicTest.php deleted file mode 100644 index 32a219b..0000000 --- a/app/tests/Unit/PureLogicTest.php +++ /dev/null @@ -1,377 +0,0 @@ -buildMetaDescription($synopsis); - } - - public function testResolveOgImage(array $files): string - { - return $this->resolveOgImage($files); - } - - public function testSplitJuryByRole(array $jury): array - { - return $this->splitJuryByRole($jury); - } - - public function testCollectCaptionPaths(array $files): array - { - return $this->collectCaptionPaths($files); - } -} - -class ThesisCreateControllerTestable extends ThesisCreateController -{ - public function testDetectFileType(string $mimeType, string $ext): string - { - return $this->detectFileType($mimeType, $ext); - } - - public function testGenerateAuthorSlug(string $name): string - { - return $this->generateAuthorSlug($name); - } -} - -// ── Setup ───────────────────────────────────────────────────────────────────── - -echo "Pure Logic Unit Test\n"; -echo "====================\n\n"; - -$db = Database::getInstance(); -$tfe = new TfeControllerTestable($db); -$createCtrl = new ThesisCreateControllerTestable($db); - -try { - - // ========================================================================= - // SECTION A: TfeController helpers - // ========================================================================= - - // ── A1: buildMetaDescription ────────────────────────────────────────────── - echo "A1: buildMetaDescription — normal synopsis\n"; - $desc = $tfe->testBuildMetaDescription('A short synopsis.'); - plAssertEq('A short synopsis.', $desc, 'short synopsis returned as-is'); - echo "\n"; - - echo "A2: buildMetaDescription — synopsis over 160 chars truncated\n"; - $long = str_repeat('a', 200); - $desc = $tfe->testBuildMetaDescription($long); - plAssert(strlen($desc) <= 160, 'length <= 160'); - plAssert(str_ends_with($desc, '…'), 'ends with ellipsis'); - echo "\n"; - - echo "A3: buildMetaDescription — exactly 160 chars not truncated\n"; - $exact = str_repeat('b', 160); - $desc = $tfe->testBuildMetaDescription($exact); - plAssertEq($exact, $desc, '160-char synopsis not truncated'); - echo "\n"; - - echo "A4: buildMetaDescription — empty synopsis returns fallback\n"; - $desc = $tfe->testBuildMetaDescription(''); - plAssert($desc !== '', 'non-empty fallback returned'); - plAssert(strlen($desc) <= 160, 'fallback length <= 160'); - echo "\n"; - - echo "A5: buildMetaDescription — HTML tags stripped\n"; - $desc = $tfe->testBuildMetaDescription('
Hello world
'); - plAssertEq('Hello world', $desc, 'HTML tags stripped'); - echo "\n"; - - // ── A6: resolveOgImage ──────────────────────────────────────────────────── - echo "A6: resolveOgImage — cover preferred over image files\n"; - $files = [ - ['file_type' => 'image', 'file_path' => 'theses/2025/img.jpg'], - ['file_type' => 'cover', 'file_path' => 'covers/cover.jpg'], - ['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'], - ]; - $url = $tfe->testResolveOgImage($files); - plAssert(str_contains($url, rawurlencode('covers/cover.jpg')), 'cover used when available'); - echo "\n"; - - echo "A7: resolveOgImage — falls back to first image when no cover\n"; - $files = [ - ['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'], - ['file_type' => 'image', 'file_path' => 'theses/2025/img.jpg'], - ['file_type' => 'image', 'file_path' => 'theses/2025/img2.png'], - ]; - $url = $tfe->testResolveOgImage($files); - plAssert(str_contains($url, rawurlencode('theses/2025/img.jpg')), 'first image file used as fallback'); - plAssert(!str_contains($url, rawurlencode('img2.png')), 'second image not used'); - echo "\n"; - - echo "A8: resolveOgImage — empty string when no image at all\n"; - $files = [ - ['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'], - ['file_type' => 'audio', 'file_path' => 'theses/2025/audio.mp3'], - ]; - $url = $tfe->testResolveOgImage($files); - plAssertEq('', $url, 'empty string when no image'); - echo "\n"; - - echo "A9: resolveOgImage — empty files array returns empty string\n"; - plAssertEq('', $tfe->testResolveOgImage([]), 'empty array → empty string'); - echo "\n"; - - // ── A10: splitJuryByRole ────────────────────────────────────────────────── - echo "A10: splitJuryByRole — all roles correctly binned\n"; - $jury = [ - ['name' => 'Alice', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0], - ['name' => 'Bob', 'role' => 'promoteur', 'is_external' => 0, 'is_ulb' => 0], - ['name' => 'Carol', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 1], - ['name' => 'Dave', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 0], - ['name' => 'Eve', 'role' => 'lecteur', 'is_external' => 0, 'is_ulb' => 0], - ['name' => 'Frank', 'role' => 'lecteur', 'is_external' => 1, 'is_ulb' => 0], - ]; - $split = $tfe->testSplitJuryByRole($jury); - - plAssertEq(['Alice'], $split['presidents'], 'president'); - plAssertEq(['Bob'], $split['internes'], 'interne promoteur'); - plAssertEq(['Carol'], $split['ulb'], 'ulb promoteur'); - plAssertEq(['Dave'], $split['externes'], 'externe promoteur (non-ULB)'); - plAssertEq(['Eve'], $split['lecteurs_internes'], 'lecteur interne'); - plAssertEq(['Frank'], $split['lecteurs_externes'], 'lecteur externe'); - echo "\n"; - - echo "A11: splitJuryByRole — empty name skipped\n"; - $jury = [['name' => '', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0]]; - $split = $tfe->testSplitJuryByRole($jury); - plAssertEq([], $split['presidents'], 'empty name not added'); - echo "\n"; - - echo "A12: splitJuryByRole — empty jury returns all-empty arrays\n"; - $split = $tfe->testSplitJuryByRole([]); - plAssertEq([], $split['presidents'], 'presidents empty'); - plAssertEq([], $split['internes'], 'internes empty'); - plAssertEq([], $split['ulb'], 'ulb empty'); - plAssertEq([], $split['externes'], 'externes empty'); - plAssertEq([], $split['lecteurs_internes'], 'lecteurs_internes empty'); - plAssertEq([], $split['lecteurs_externes'], 'lecteurs_externes empty'); - echo "\n"; - - // ── A13: collectCaptionPaths ────────────────────────────────────────────── - echo "A13: collectCaptionPaths — VTT files extracted in order\n"; - $files = [ - ['mime_type' => 'application/pdf', 'file_path' => 'main.pdf'], - ['mime_type' => 'text/vtt', 'file_path' => 'captions1.vtt'], - ['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'], - ['mime_type' => 'text/plain', 'file_path' => 'captions2.vtt'], - ]; - $captions = $tfe->testCollectCaptionPaths($files); - plAssertEq(['captions1.vtt', 'captions2.vtt'], $captions, 'both VTT files returned in order'); - echo "\n"; - - echo "A14: collectCaptionPaths — .vtt extension without mime match\n"; - $files = [ - ['mime_type' => 'application/octet-stream', 'file_path' => 'sub.vtt'], - ['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'], - ]; - $captions = $tfe->testCollectCaptionPaths($files); - plAssertEq(['sub.vtt'], $captions, '.vtt extension matches even with generic mime'); - echo "\n"; - - echo "A15: collectCaptionPaths — no VTT returns empty array\n"; - $files = [ - ['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'], - ]; - plAssertEq([], $tfe->testCollectCaptionPaths($files), 'empty array when no VTT'); - echo "\n"; - - // ========================================================================= - // SECTION B: ThesisCreateController helpers - // ========================================================================= - - // ── B1: autofocusFieldForError ──────────────────────────────────────────── - echo "B1: autofocusFieldForError — known error messages map to fields\n"; - $cases = [ - ['Titre du 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); - plAssertEq($expected, $actual, "\"$message\" → $expected"); - } - echo "\n"; - - echo "B2: autofocusFieldForError — unknown message returns null\n"; - plAssertEq(null, ThesisCreateController::autofocusFieldForError('completely unknown'), 'null for unknown'); - echo "\n"; - - // ── B3: detectFileType ──────────────────────────────────────────────────── - echo "B3: detectFileType — mime-based detection\n"; - $cases = [ - ['text/vtt', 'vtt', 'caption'], - ['audio/mpeg', 'mp3', 'audio'], - ['audio/ogg', 'ogg', 'audio'], - ['video/mp4', 'mp4', 'video'], - ['video/webm', 'webm', 'video'], - ['application/pdf', 'pdf', 'main'], - ['image/jpeg', 'jpg', 'image'], - ['image/png', 'png', 'image'], - ['application/zip', 'zip', 'other'], - ]; - foreach ($cases as [$mime, $ext, $expected]) { - $actual = $createCtrl->testDetectFileType($mime, $ext); - plAssertEq($expected, $actual, "$mime / $ext → $expected"); - } - echo "\n"; - - echo "B4: detectFileType — extension-based fallback\n"; - // application/octet-stream with known extensions - $cases = [ - ['application/octet-stream', 'mp3', 'audio'], - ['application/octet-stream', 'mp4', 'video'], - ['application/octet-stream', 'pdf', 'main'], - ['application/octet-stream', 'webp', 'image'], - ['application/octet-stream', 'vtt', 'caption'], - ]; - foreach ($cases as [$mime, $ext, $expected]) { - $actual = $createCtrl->testDetectFileType($mime, $ext); - plAssertEq($expected, $actual, "octet-stream + .$ext → $expected"); - } - echo "\n"; - - // ── B5: generateAuthorSlug ──────────────────────────────────────────────── - echo "B5: generateAuthorSlug — basic ASCII\n"; - plAssertEq('JANE_DOE', $createCtrl->testGenerateAuthorSlug('Jane Doe'), 'spaces to underscores, uppercase'); - plAssertEq('AUTHOR', $createCtrl->testGenerateAuthorSlug(''), 'empty → AUTHOR'); - echo "\n"; - - echo "B6: generateAuthorSlug — French accents stripped\n"; - plAssertEq('ELEONORE_DUPONT', $createCtrl->testGenerateAuthorSlug('Éléonore Dupont'), 'accents stripped'); - plAssertEq('FRANCOISE', $createCtrl->testGenerateAuthorSlug('Françoise'), 'ç → C'); - echo "\n"; - - echo "B7: generateAuthorSlug — multiple authors comma-separated\n"; - $slug = $createCtrl->testGenerateAuthorSlug('Alice Martin, Bob Durand'); - plAssert(str_contains($slug, 'ALICE'), 'contains ALICE'); - plAssert(str_contains($slug, 'BOB'), 'contains BOB'); - echo "\n"; - - // ========================================================================= - // SECTION C: ExportController — CSV column count consistency - // ========================================================================= - - echo "C1: ExportController — CSV_HEADERS count matches row column count\n"; - $export = new ExportController($db); - $rows = $export->exportAllTheses(); - $headerCount = count(ExportController::CSV_HEADERS); - - plAssert($headerCount > 0, 'CSV_HEADERS is non-empty'); - - if (!empty($rows)) { - foreach ($rows as $i => $row) { - if (count($row) !== $headerCount) { - throw new RuntimeException( - "FAIL: row $i has " . count($row) . " columns, expected $headerCount" - ); - } - } - echo ' ✓ all ' . count($rows) . " rows have $headerCount columns matching CSV_HEADERS\n"; - } else { - echo " ✓ no rows to check (empty export) — header count is $headerCount\n"; - } - echo "\n"; - - // ========================================================================= - // SECTION D: SearchController — coverMap key always present (regression) - // ========================================================================= - - echo "D1: SearchController::handleSearch() — coverMap key always in return array\n"; - // Simulate $_GET for the method (it reads from $_GET directly via collectSearchParams) - $_GET = ['query' => '']; - $rateLimit = new RateLimit(1000, 60); - $searchCtrl = new SearchController($db, $rateLimit); - $vars = $searchCtrl->handleSearch(); - plAssert(array_key_exists('coverMap', $vars), 'coverMap key present in handleSearch() return'); - plAssert(is_array($vars['coverMap']), 'coverMap is an array'); - $_GET = []; - echo "\n"; - - echo "✅ All pure logic tests passed!\n"; - $result = true; - -} catch (Exception $e) { - echo '❌ FAIL: ' . $e->getMessage() . "\n"; - $result = false; -} - -return $result ?? false; diff --git a/app/tests/Unit/RateLimitTest.php b/app/tests/Unit/RateLimitTest.php deleted file mode 100644 index 6e7adf3..0000000 --- a/app/tests/Unit/RateLimitTest.php +++ /dev/null @@ -1,55 +0,0 @@ -check(); - if (is_bool($allowed)) { - echo '✓ PASS: check() returns boolean (allowed: ' . ($allowed ? 'yes' : 'no') . ")\n\n"; - } else { - throw new Exception('check() did not return boolean'); - } - - // Test 3: Headers method - echo "Test 3: Send Headers Method\n"; - ob_start(); - $rateLimit->sendHeaders(); - ob_end_clean(); - echo "✓ PASS: sendHeaders() executed without error\n\n"; - - // Test 4: Get reset time - echo "Test 4: Get Reset Time\n"; - $resetTime = $rateLimit->getResetTime(); - if (is_int($resetTime) && $resetTime >= 0) { - echo "✓ PASS: getResetTime() returns valid value ($resetTime seconds)\n\n"; - } else { - throw new Exception('Invalid reset time'); - } - - // Test 5: Cleanup method - echo "Test 5: Cleanup Method\n"; - $rateLimit->cleanup(); - echo "✓ PASS: cleanup() executed without error\n\n"; - - echo "✅ All rate limit tests passed!\n"; - return true; - -} catch (Exception $e) { - echo '❌ FAIL: ' . $e->getMessage() . "\n"; - return false; -} diff --git a/app/tests/Unit/ShareLinkTest.php b/app/tests/Unit/ShareLinkTest.php deleted file mode 100644 index 16f1fa2..0000000 --- a/app/tests/Unit/ShareLinkTest.php +++ /dev/null @@ -1,228 +0,0 @@ -= 2020 && $year <= 2100, 'year in plausible range'); - slAssert($month >= 1 && $month <= 12, 'month in range'); - slAssert($day >= 1 && $day <= 31, 'day in range'); - echo "\n"; - - // ========================================================================= - // TEST 2: generateSlug — two calls produce different slugs - // ========================================================================= - echo "Test 2: generateSlug — uniqueness\n"; - $slugs = []; - for ($i = 0; $i < 20; $i++) { - $slugs[] = ShareLink::generateSlug(); - } - slAssertEq(count($slugs), count(array_unique($slugs)), '20 consecutive slugs are all unique'); - echo "\n"; - - // ========================================================================= - // TEST 3: validateLink — not_found on missing slug - // ========================================================================= - echo "Test 3: validateLink — not_found on missing slug\n"; - $result = $model->validateLink('NONEXISTENT-SLUG'); - slAssertEq(false, $result['valid'], 'valid=false'); - slAssertEq('not_found', $result['reason'], 'reason=not_found'); - - $result = $model->validateLink(null); - slAssertEq(false, $result['valid'], 'null slug: valid=false'); - slAssertEq('not_found', $result['reason'], 'null slug: reason=not_found'); - - $result = $model->validateLink(''); - slAssertEq(false, $result['valid'], 'empty slug: valid=false'); - slAssertEq('not_found', $result['reason'], 'empty slug: reason=not_found'); - echo "\n"; - - // ========================================================================= - // TEST 4: validateLink — valid active link with no password - // ========================================================================= - echo "Test 4: validateLink — link with auto-generated password needs password\n"; - $link = $model->create($adminId, null, null); - $createdIds[] = $link['id']; - - $result = $model->validateLink($link['slug']); - 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"; - - // ========================================================================= - // TEST 5: validateLink — disabled link - // ========================================================================= - echo "Test 5: validateLink — disabled link\n"; - $model->toggleActive($link['id']); // deactivate - $result = $model->validateLink($link['slug']); - slAssertEq(false, $result['valid'], 'valid=false after disable'); - slAssertEq('disabled', $result['reason'], 'reason=disabled'); - $model->toggleActive($link['id']); // restore - echo "\n"; - - // ========================================================================= - // TEST 6: validateLink — archived link - // ========================================================================= - echo "Test 6: validateLink — archived link\n"; - $archivedLink = $model->create($adminId, null, null); - $createdIds[] = $archivedLink['id']; - $model->archive($archivedLink['id']); - $result = $model->validateLink($archivedLink['slug']); - slAssertEq(false, $result['valid'], 'valid=false for archived'); - slAssertEq('archived', $result['reason'], 'reason=archived'); - echo "\n"; - - // ========================================================================= - // TEST 7: validateLink — expired link (needs_password takes priority) - // ========================================================================= - echo "Test 7: validateLink — expired link with password\n"; - $pastDate = date('Y-m-d H:i:s', strtotime('-1 day')); - $expiredLink = $model->create($adminId, $pastDate); - $createdIds[] = $expiredLink['id']; - $result = $model->validateLink($expiredLink['slug']); - slAssertEq(false, $result['valid'], 'valid=false'); - slAssertEq('expired', $result['reason'], 'reason=expired'); - echo "\n"; - - // ========================================================================= - // TEST 8: validateLink — needs_password (all links have passwords now) - // ========================================================================= - 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('needs_password', $result['reason'], 'reason=needs_password'); - slAssert(isset($result['link']), 'link row returned even when password needed'); - echo "\n"; - - // ========================================================================= - // TEST 9: verifyPassword — correct auto-generated password - // ========================================================================= - echo "Test 9: verifyPassword — correct auto-generated password\n"; - $pwLinkRow = $model->findBySlug($pwLink['slug']); - $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'); - echo "\n"; - - // ========================================================================= - // TEST 10: verifyPassword — any link requires correct password - // ========================================================================= - 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"; - - // ========================================================================= - // TEST 12: incrementUsage — counter goes up - // ========================================================================= - echo "Test 12: incrementUsage — counter increments\n"; - $fresh = $model->findById($link['id']); - $before = (int)$fresh['usage_count']; - $model->incrementUsage($link['id']); - $model->incrementUsage($link['id']); - $after = (int)($model->findById($link['id'])['usage_count'] ?? 0); - slAssertEq($before + 2, $after, 'usage_count incremented by 2'); - echo "\n"; - - // ========================================================================= - // TEST 13: objet_restriction is stored and returned - // ========================================================================= - echo "Test 13: objet_restriction stored correctly\n"; - $restrictedLink = $model->create($adminId, null, null, 'tfe'); - $createdIds[] = $restrictedLink['id']; - slAssertEq('tfe', $restrictedLink['objet_restriction'], 'objet_restriction=tfe stored'); - - $anyLink = $model->create($adminId, null, null, 'invalid_value'); - $createdIds[] = $anyLink['id']; - slAssertEq('tfe', $anyLink['objet_restriction'], 'invalid objet_restriction defaults to tfe'); - echo "\n"; - - echo "✅ All ShareLink tests passed!\n"; - $result = true; - -} catch (Exception $e) { - echo '❌ FAIL: ' . $e->getMessage() . "\n"; - $result = false; -} finally { - $pdo = $db->getConnection(); - foreach ($createdIds as $id) { - try { - $pdo->prepare('DELETE FROM share_links WHERE id = ?')->execute([$id]); - } catch (Exception $e) { /* ignore */ - } - } -} - -return $result ?? false; diff --git a/app/tests/run-tests.php b/app/tests/run-tests.php deleted file mode 100755 index dd1e793..0000000 --- a/app/tests/run-tests.php +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env php - 'Database (Unit)', 'path' => __DIR__ . '/Unit/DatabaseTest.php'], - ['name' => 'Rate Limit (Unit)', 'path' => __DIR__ . '/Unit/RateLimitTest.php'], - ['name' => 'Form Save Round-Trip (Unit)', 'path' => __DIR__ . '/Unit/FormSaveTest.php'], - ['name' => 'ShareLink (Unit)', 'path' => __DIR__ . '/Unit/ShareLinkTest.php'], - ['name' => 'ErrorHandler (Unit)', 'path' => __DIR__ . '/Unit/ErrorHandlerTest.php'], - ['name' => 'Pure Logic (Unit)', 'path' => __DIR__ . '/Unit/PureLogicTest.php'], - ['name' => 'Search (Integration)', 'path' => __DIR__ . '/Integration/SearchTest.php'], - ['name' => 'Security', 'path' => __DIR__ . '/Security/SecurityTest.php'], -]; - -$totalTests = 0; -$passedTests = 0; -$failedTests = 0; -$skippedTests = 0; - -foreach ($testFiles as $test) { - echo "┌─────────────────────────────────────────┐\n"; - echo '│ ' . str_pad($test['name'], 41) . "│\n"; - echo "└─────────────────────────────────────────┘\n\n"; - - $totalTests++; - $path = $test['path']; - $file = basename($path); - - if (!file_exists($path)) { - echo "⚠️ SKIP: $file (not found)\n\n"; - $skippedTests++; - continue; - } - - ob_start(); - $exitCode = 0; - $testResult = false; - - try { - $testResult = include $path; - - // Check if test returned false or had error indicators in output - $output = ob_get_contents(); - if ($testResult === false || - strpos($output, '❌') !== false || - strpos($output, 'FAIL:') !== false) { - $exitCode = 1; - } - } catch (Exception $e) { - $exitCode = 1; - echo '❌ EXCEPTION: ' . $e->getMessage() . "\n"; - } - - $output = ob_get_clean(); - echo $output; - - if ($exitCode === 0 && $testResult !== false) { - echo "\n✅ TEST PASSED\n\n"; - $passedTests++; - } else { - echo "\n❌ TEST FAILED\n\n"; - $failedTests++; - } -} - -echo "╔════════════════════════════════════════════╗\n"; -echo "║ Test Summary ║\n"; -echo "╠════════════════════════════════════════════╣\n"; -echo '║ Total: ' . str_pad($totalTests, 34) . "║\n"; -echo '║ Passed: ' . str_pad($passedTests . ' ✅', 35) . "║\n"; -echo '║ Failed: ' . str_pad($failedTests . ($failedTests > 0 ? ' ❌' : ''), 35) . "║\n"; -if ($skippedTests > 0) { - echo '║ Skipped: ' . str_pad($skippedTests . ' ⚠️', 36) . "║\n"; -} -echo "╚════════════════════════════════════════════╝\n\n"; - -if ($failedTests > 0) { - echo "❌ Some tests failed!\n"; - exit(1); -} else { - echo "✅ All tests passed!\n"; - exit(0); -} diff --git a/justfile b/justfile index d4db6d4..96af243 100644 --- a/justfile +++ b/justfile @@ -339,10 +339,13 @@ deploy-all-first: deploy deploy-backup [group('test')] test: - # Run all tests. To run a subset, use: - # php app/tests/Unit/DatabaseTest.php - # php app/tests/Integration/SearchTest.php - @php app/tests/run-tests.php + # Run all PHPUnit tests + @vendor/bin/phpunit tests/phpunit/ + +[group('test')] +test-coverage: + # Generate HTML coverage report in coverage/ + @vendor/bin/phpunit --coverage-html coverage/ tests/phpunit/ [group('test')] lint-biome: diff --git a/tests/phpunit/ErrorHandlerTest.php b/tests/phpunit/ErrorHandlerTest.php new file mode 100644 index 0000000..0db06b7 --- /dev/null +++ b/tests/phpunit/ErrorHandlerTest.php @@ -0,0 +1,265 @@ +makeFkException( + 'FOREIGN KEY constraint failed INSERT INTO theses (title, orientation_id) VALUES (?,?)' + ); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Orientation', $user); + $this->assertStringContainsString('AP', $user); + $this->assertStringContainsString('Licence', $user); + $this->assertStringNotContainsString('FOREIGN KEY', $user); + } + + public function testFkApPrograms(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (ap_program_id) VALUES (?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('AP', $user); + } + + public function testFkFinalityTypes(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (finality_id) VALUES (?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Finalité', $user); + } + + public function testFkThesisLanguages(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Langue(s)', $user); + } + + public function testFkThesisFormats(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Format(s)', $user); + } + + public function testFkThesisTags(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Mots-clés', $user); + } + + public function testFkThesisSupervisors(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_supervisors (thesis_id, supervisor_id) VALUES (?,?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Composition du jury', $user); + } + + public function testFkAccessTypes(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (access_type_id) VALUES (?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString("Type d'accès", $user); + } + + public function testFkLicenseTypes(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (license_id) VALUES (?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Licence', $user); + } + + public function testFkAuthors(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_authors (thesis_id, author_id) VALUES (?,?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Auteur·ice', $user); + } + + // ── FK constraint: "table" pattern (SQLite 3.37+) ──────────────────────── + + public function testFkQuotedTableName(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed (table "orientations")'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Orientation', $user); + } + + public function testFkQuotedLanguages(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed (table "languages")'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Langue(s)', $user); + } + + public function testFkQuotedFormatTypes(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed (table "format_types")'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Format(s)', $user); + } + + // ── FK constraint: REFERENCES pattern ──────────────────────────────────── + + public function testFkReferencesTags(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed (REFERENCES tags(id))'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Mots-clés', $user); + } + + public function testFkReferencesOrientations(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed (REFERENCES orientations(id))'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Orientation', $user); + } + + // ── FK constraint: unknown table → generic ─────────────────────────────── + + public function testFkUnknownTableGenericFallback(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO unknown_table VALUES (?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('contrainte de référence est invalide', $user); + $this->assertStringNotContainsString('unknown_table', $user); + } + + public function testFkEmptyMessageGenericFallback(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('contrainte de référence est invalide', $user); + } + + // ── UNIQUE constraint ──────────────────────────────────────────────────── + + public function testUniqueConstraint(): void + { + $msg = new PDOException('UNIQUE constraint failed: thesis_tags.tag_id, thesis_tags.thesis_id', 2067); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('valeur en double', $user); + $this->assertStringNotContainsString('UNIQUE', $user); + $this->assertStringNotContainsString('thesis_tags', $user); + } + + // ── NOT NULL constraint ────────────────────────────────────────────────── + + public function testNotNullConstraint(): void + { + $msg = new PDOException('NOT NULL constraint failed: theses.title', 1299); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('champ obligatoire est manquant', $user); + $this->assertStringNotContainsString('NOT NULL', $user); + } + + // ── Generic PDO error ──────────────────────────────────────────────────── + + public function testGenericPdoError(): void + { + $msg = new PDOException('database disk image is malformed', 11); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Une erreur de base de données est survenue', $user); + $this->assertStringNotContainsString('disk image', $user); + } + + // ── Domain exceptions pass through ─────────────────────────────────────── + + public function testDuplicateThesisExceptionPassesThrough(): void + { + $dup = new DuplicateThesisException(42, '2025-ABC12345', 'Test titre', 'Auteur', 2025); + $user = ErrorHandler::userMessage($dup); + $this->assertStringContainsString('2025-ABC12345', $user); + $this->assertStringContainsString('Auteur', $user); + } + + public function testValidationExceptionPassesThrough(): void + { + $val = new RuntimeException('Le titre est requis.'); + $user = ErrorHandler::userMessage($val); + $this->assertSame('Le titre est requis.', $user); + } + + // ── Unknown exception types → generic fallback ─────────────────────────── + + public function testGenericExceptionPassesThrough(): void + { + $gen = new Exception('Something went wrong'); + $user = ErrorHandler::userMessage($gen); + $this->assertStringContainsString('Something went wrong', $user); + } + + public function testTypeErrorReturnsGeneric(): void + { + $typeErr = new TypeError('htmlspecialchars(): Argument #1 must be string, array given'); + $user = ErrorHandler::userMessage($typeErr); + $this->assertStringContainsString('Une erreur inattendue est survenue', $user); + $this->assertStringNotContainsString('htmlspecialchars', $user); + } + + // ── log() does not crash ───────────────────────────────────────────────── + + public function testLogWithContext(): void + { + // Should not throw + ErrorHandler::log('test_context', new Exception('test message'), [ + 'thesis_id' => 42, + 'slug' => '20250101-TEST1234', + ]); + $this->assertTrue(true); // reached here = no crash + } + + public function testLogWithNullValues(): void + { + ErrorHandler::log('test_null', new PDOException('test'), ['id' => null, 'name' => 'foo']); + $this->assertTrue(true); + } + + public function testLogWithEmptyExtra(): void + { + ErrorHandler::log('test_empty', new RuntimeException('bare')); + $this->assertTrue(true); + } + + // ── Real-world FK error patterns ───────────────────────────────────────── + + public function testFkQuotedColumnNames(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats ("thesis_id", "format_id") VALUES (?, ?)'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Format(s)', $user); + } + + public function testFkUpdateStatement(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed UPDATE theses SET orientation_id = ? WHERE id = ?'); + $user = ErrorHandler::userMessage($msg); + $this->assertStringContainsString('Orientation', $user); + } + + public function testFkWithReferencesAndInsert(): void + { + $msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?) REFERENCES format_types'); + $user = ErrorHandler::userMessage($msg); + // First matcher wins (INSERT table) + $this->assertStringContainsString('Format(s)', $user); + } +} diff --git a/tests/phpunit/PureLogicTest.php b/tests/phpunit/PureLogicTest.php new file mode 100644 index 0000000..c0ebd9c --- /dev/null +++ b/tests/phpunit/PureLogicTest.php @@ -0,0 +1,144 @@ +splitJuryByRole($jury); } + public function exposedCollectCaptionPaths(array $files): array { return $this->collectCaptionPaths($files); } + }; + } + + private function getThesisCreateController(): ThesisCreateController + { + $db = TestDatabase::getInstance(); + return new class($db) extends ThesisCreateController { + public function exposedDetectFileType(string $mimeType, string $ext): string { return $this->detectFileType($mimeType, $ext); } + }; + } + + // ── splitJuryByRole ────────────────────────────────────────────────────── + + public function testSplitJuryByRoleAllRoles(): void + { + $ctrl = $this->getTfeController(); + $jury = [ + ['name' => 'Alice', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0], + ['name' => 'Bob', 'role' => 'promoteur', 'is_external' => 0, 'is_ulb' => 0], + ['name' => 'Carol', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 1], + ['name' => 'Dave', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 0], + ['name' => 'Eve', 'role' => 'lecteur', 'is_external' => 0, 'is_ulb' => 0], + ['name' => 'Frank', 'role' => 'lecteur', 'is_external' => 1, 'is_ulb' => 0], + ]; + $split = $ctrl->exposedSplitJuryByRole($jury); + + $this->assertSame(['Alice'], $split['presidents']); + $this->assertSame(['Bob'], $split['internes']); + $this->assertSame(['Carol'], $split['ulb']); + $this->assertSame(['Dave'], $split['externes']); + $this->assertSame(['Eve'], $split['lecteurs_internes']); + $this->assertSame(['Frank'], $split['lecteurs_externes']); + } + + public function testSplitJuryByRoleEmptyNameSkipped(): void + { + $ctrl = $this->getTfeController(); + $jury = [['name' => '', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0]]; + $split = $ctrl->exposedSplitJuryByRole($jury); + + $this->assertEmpty($split['presidents']); + } + + public function testSplitJuryByRoleEmptyJury(): void + { + $ctrl = $this->getTfeController(); + $split = $ctrl->exposedSplitJuryByRole([]); + + $this->assertEmpty($split['presidents']); + $this->assertEmpty($split['internes']); + $this->assertEmpty($split['ulb']); + $this->assertEmpty($split['externes']); + $this->assertEmpty($split['lecteurs_internes']); + $this->assertEmpty($split['lecteurs_externes']); + } + + // ── collectCaptionPaths ────────────────────────────────────────────────── + + public function testCollectCaptionPathsVttByMime(): void + { + $ctrl = $this->getTfeController(); + $files = [ + ['mime_type' => 'application/pdf', 'file_path' => 'main.pdf'], + ['mime_type' => 'text/vtt', 'file_path' => 'captions1.vtt'], + ['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'], + ['mime_type' => 'text/plain', 'file_path' => 'captions2.vtt'], + ]; + $captions = $ctrl->exposedCollectCaptionPaths($files); + + // Only the VTT files, in order + $this->assertCount(2, $captions); + $this->assertSame('captions1.vtt', $captions[0]); + $this->assertSame('captions2.vtt', $captions[1]); + } + + public function testCollectCaptionPathsVttByExtension(): void + { + $ctrl = $this->getTfeController(); + $files = [ + ['mime_type' => 'application/octet-stream', 'file_path' => 'sub.vtt'], + ['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'], + ]; + $captions = $ctrl->exposedCollectCaptionPaths($files); + + $this->assertCount(1, $captions); + $this->assertSame('sub.vtt', $captions[0]); + } + + public function testCollectCaptionPathsNoVttReturnsEmpty(): void + { + $ctrl = $this->getTfeController(); + $files = [['mime_type' => 'video/mp4', 'file_path' => 'video.mp4']]; + + $this->assertEmpty($ctrl->exposedCollectCaptionPaths($files)); + } + + // ── detectFileType ─────────────────────────────────────────────────────── + + public function testDetectFileTypeByMime(): void + { + $ctrl = $this->getThesisCreateController(); + + $this->assertSame('caption', $ctrl->exposedDetectFileType('text/vtt', 'vtt')); + $this->assertSame('audio', $ctrl->exposedDetectFileType('audio/mpeg', 'mp3')); + $this->assertSame('audio', $ctrl->exposedDetectFileType('audio/ogg', 'ogg')); + $this->assertSame('video', $ctrl->exposedDetectFileType('video/mp4', 'mp4')); + $this->assertSame('video', $ctrl->exposedDetectFileType('video/webm', 'webm')); + $this->assertSame('main', $ctrl->exposedDetectFileType('application/pdf', 'pdf')); + $this->assertSame('image', $ctrl->exposedDetectFileType('image/jpeg', 'jpg')); + $this->assertSame('image', $ctrl->exposedDetectFileType('image/png', 'png')); + $this->assertSame('other', $ctrl->exposedDetectFileType('application/zip', 'zip')); + } + + public function testDetectFileTypeByExtensionFallback(): void + { + $ctrl = $this->getThesisCreateController(); + + $this->assertSame('audio', $ctrl->exposedDetectFileType('application/octet-stream', 'mp3')); + $this->assertSame('video', $ctrl->exposedDetectFileType('application/octet-stream', 'mp4')); + $this->assertSame('main', $ctrl->exposedDetectFileType('application/octet-stream', 'pdf')); + $this->assertSame('image', $ctrl->exposedDetectFileType('application/octet-stream', 'webp')); + $this->assertSame('caption', $ctrl->exposedDetectFileType('application/octet-stream', 'vtt')); + } +} diff --git a/tests/phpunit/SearchControllerTest.php b/tests/phpunit/SearchControllerTest.php new file mode 100644 index 0000000..2a5a543 --- /dev/null +++ b/tests/phpunit/SearchControllerTest.php @@ -0,0 +1,52 @@ + '']; + } + + protected function tearDown(): void + { + $_GET = []; + } + + public function testHandleSearchReturnsCoverMapKey(): void + { + $db = TestDatabase::getInstance(); + $rateLimit = new RateLimit(1000, 60, sys_get_temp_dir() . '/xamxam_rl_test_' . uniqid()); + $searchCtrl = new SearchController($db, $rateLimit); + + $vars = $searchCtrl->handleSearch(); + + $this->assertArrayHasKey('coverMap', $vars); + $this->assertIsArray($vars['coverMap']); + } + + public function testCoverMapContainsKnownThesis(): void + { + $pdo = TestDatabase::getPDO(); + $thesisId = $pdo->query('SELECT id FROM theses LIMIT 1')->fetchColumn(); + + $db = TestDatabase::getInstance(); + $rateLimit = new RateLimit(1000, 60, sys_get_temp_dir() . '/xamxam_rl_test2_' . uniqid()); + $searchCtrl = new SearchController($db, $rateLimit); + + $vars = $searchCtrl->handleSearch(); + + if (!empty($vars['results'])) { + $this->assertArrayHasKey((int)$thesisId, $vars['coverMap']); + } + } +}