From 93625d09b58b8ba74819b00103110ccc3bdd39b8 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Wed, 20 May 2026 01:51:41 +0200 Subject: [PATCH] Add integration tests (Phase 2: DatabaseExtended, ShareLinkExtended, RateLimitExtended) and controller validation tests (Phase 3: ThesisCreate, ThesisEdit, AutofocusField) --- .phpunit.result.cache | 2 +- TODO.md | 14 +- tests/TestDatabase.php | 142 ++++++++ tests/bootstrap.php | 3 + tests/phpunit/AutofocusFieldForErrorTest.php | 138 ++++++++ tests/phpunit/DatabaseExtendedTest.php | 353 +++++++++++++++++++ tests/phpunit/RateLimitExtendedTest.php | 156 ++++++++ tests/phpunit/ShareLinkExtendedTest.php | 204 +++++++++++ tests/phpunit/ThesisCreateValidationTest.php | 341 ++++++++++++++++++ tests/phpunit/ThesisEditValidationTest.php | 198 +++++++++++ 10 files changed, 1543 insertions(+), 8 deletions(-) create mode 100644 tests/TestDatabase.php create mode 100644 tests/phpunit/AutofocusFieldForErrorTest.php create mode 100644 tests/phpunit/DatabaseExtendedTest.php create mode 100644 tests/phpunit/RateLimitExtendedTest.php create mode 100644 tests/phpunit/ShareLinkExtendedTest.php create mode 100644 tests/phpunit/ThesisCreateValidationTest.php create mode 100644 tests/phpunit/ThesisEditValidationTest.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 084b244..4faa9de 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},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0.002,"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.001,"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.002,"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}} \ 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,"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 diff --git a/TODO.md b/TODO.md index c008687..253cc87 100644 --- a/TODO.md +++ b/TODO.md @@ -14,15 +14,15 @@ - [x] 1.5 `TfeControllerOgTest.php` — buildOgTags: required keys, image fallback, description truncation ## Phase 2 — Integration (requires test database) -- [ ] 2.0 Setup: `tests/fixtures/`, TestDatabase helper, `.env.test` -- [ ] 2.1 `DatabaseExtendedTest.php` — escapeLikeString, buildSearchConditions, findDuplicateThesis, generateThesisIdentifier, getCoverPathsForTheses, findOrCreateAuthor, deduplicateLanguages/renameLanguage/mergeLanguage, renameTag/mergeTag -- [ ] 2.2 `ShareLinkExtendedTest.php` — listActive, listArchived, findBySlug, setPassword+getDecryptedPassword round-trip, update -- [ ] 2.3 `RateLimitExtendedTest.php` — checkKey per-key counts, getRemaining decrement, getClientIdentifier consistency +- [x] 2.0 Setup: `tests/fixtures/`, TestDatabase helper, `.env.test` +- [x] 2.1 `DatabaseExtendedTest.php` — escapeLikeString, buildSearchConditions, findDuplicateThesis, generateThesisIdentifier, getCoverPathsForTheses, findOrCreateAuthor, deduplicateLanguages/renameLanguage/mergeLanguage, renameTag/mergeTag +- [x] 2.2 `ShareLinkExtendedTest.php` — listActive, listArchived, findBySlug, setPassword+getDecryptedPassword round-trip, update +- [x] 2.3 `RateLimitExtendedTest.php` — checkKey per-key counts, getRemaining decrement, getClientIdentifier consistency ## Phase 3 — Controller Validation -- [ ] 3.1 `ThesisCreateValidationTest.php` — valid submission, missing required fields, invalid year, malformed URL, tag dedup, XSS escaping -- [ ] 3.2 `ThesisEditValidationTest.php` — load known/404, collectJuryMembers, handleWebsiteUrl normalisation -- [ ] 3.3 `AutofocusFieldForErrorTest.php` — correct field per error key, unknown key returns null/default, no CreateController name leak +- [x] 3.1 `ThesisCreateValidationTest.php` — valid submission, missing required fields, invalid year, malformed URL, tag dedup, XSS escaping +- [x] 3.2 `ThesisEditValidationTest.php` — load known/404, collectJuryMembers, handleWebsiteUrl normalisation +- [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/` diff --git a/tests/TestDatabase.php b/tests/TestDatabase.php new file mode 100644 index 0000000..ab2eafb --- /dev/null +++ b/tests/TestDatabase.php @@ -0,0 +1,142 @@ +setAccessible(true); + $ref->setValue($this, $pdo); + + $pathRef = new ReflectionProperty(Database::class, 'dbPath'); + $pathRef->setAccessible(true); + $pathRef->setValue($this, ':memory:'); + } +} + +class TestDatabase +{ + private static ?PDO $pdo = null; + private static ?Database $db = null; + + /** + * Get or create the shared test Database instance. + * Uses an in-memory SQLite DB so tests are fast and isolated. + */ + public static function getInstance(): Database + { + if (self::$db === null) { + self::$pdo = new PDO('sqlite::memory:'); + self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + self::$pdo->exec('PRAGMA foreign_keys = ON'); + self::$pdo->exec('PRAGMA journal_mode = MEMORY'); + + // Load schema + $schema = file_get_contents(APP_ROOT . '/storage/schema.sql'); + if ($schema === false) { + throw new RuntimeException('Failed to read schema.sql'); + } + self::$pdo->exec($schema); + + // Create a Database wrapper injecting our PDO + self::$db = new TestDatabaseInstance(self::$pdo); + } + + return self::$db; + } + + /** + * Get the raw PDO connection (for queries that bypass the Database class). + */ + public static function getPDO(): PDO + { + // Ensure the DB is booted + self::getInstance(); + return self::$pdo; + } + + /** + * Reset all test data between tests. + * Preserves seed data (orientations, access types, etc.) but removes + * any theses, authors, tags, share links etc. created during a test. + */ + public static function resetData(): void + { + $pdo = self::getPDO(); + // Order matters due to FK constraints + $tables = [ + 'file_access_audit', + 'file_access_sessions', + 'file_access_tokens', + 'file_access_requests', + 'thesis_files', + 'thesis_tags', + 'thesis_formats', + 'thesis_languages', + 'thesis_supervisors', + 'thesis_authors', + 'theses', + 'share_links', + 'tags', + 'authors', + 'supervisors', + 'admin_audit_log', + 'audit_log', + ]; + foreach ($tables as $table) { + $pdo->exec("DELETE FROM $table"); + } + // Re-seed tags (some tests rely on tags existing) + try { + $pdo->exec("DELETE FROM tags WHERE deleted_at IS NOT NULL"); + } catch (Exception $e) { + // tags table already empty + } + } + + /** + * Seed some basic test data: an author, a thesis. + * Returns [authorId, thesisId]. + * + * @return array{0: int, 1: int} + */ + public static function seedBasicThesis(string $title = 'Test Thesis', string $authorName = 'Test Author', int $year = 2024): array + { + $pdo = self::getPDO(); + + // Insert author + $pdo->prepare('INSERT INTO authors (name) VALUES (?)')->execute([$authorName]); + $authorId = (int)$pdo->lastInsertId(); + + // Insert thesis + $pdo->prepare( + "INSERT INTO theses (title, year, identifier, is_published, objet) VALUES (?, ?, ?, 1, 'tfe')" + )->execute([$title, $year, "$year-001"]); + + $thesisId = (int)$pdo->lastInsertId(); + + // Link author + $pdo->prepare('INSERT INTO thesis_authors (thesis_id, author_id) VALUES (?, ?)') + ->execute([$thesisId, $authorId]); + + // Insert a cover file + $pdo->prepare( + "INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type) VALUES (?, 'cover', ?, ?, 0, 'image/jpeg')" + )->execute([$thesisId, "documents/$year-001/cover.jpg", 'cover.jpg']); + + return [$authorId, $thesisId]; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a0148ad..7c2d680 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,3 +15,6 @@ define('APP_ROOT', realpath(__DIR__ . '/../app')); // Storage directory for tests — use app/storage/ define('STORAGE_ROOT', APP_ROOT . '/storage'); + +// Test helpers +require_once __DIR__ . '/TestDatabase.php'; diff --git a/tests/phpunit/AutofocusFieldForErrorTest.php b/tests/phpunit/AutofocusFieldForErrorTest.php new file mode 100644 index 0000000..fa49bbf --- /dev/null +++ b/tests/phpunit/AutofocusFieldForErrorTest.php @@ -0,0 +1,138 @@ +assertSame('titre', ThesisCreateController::autofocusFieldForError("Le champ 'Titre du TFE' est requis.")); + } + + public function testCreateAutofocusAuthors(): void + { + $this->assertSame('auteurice', ThesisCreateController::autofocusFieldForError("Le champ 'Auteur·ice(s)' est requis.")); + } + + public function testCreateAutofocusSynopsis(): void + { + $this->assertSame('synopsis', ThesisCreateController::autofocusFieldForError("Le champ 'Synopsis' est requis.")); + } + + public function testCreateAutofocusYear(): void + { + $this->assertSame('année', ThesisCreateController::autofocusFieldForError('Année invalide. Veuillez entrer une année valide.')); + } + + public function testCreateAutofocusOrientation(): void + { + $this->assertSame('orientation', ThesisCreateController::autofocusFieldForError('Veuillez sélectionner une orientation.')); + } + + public function testCreateAutofocusAP(): void + { + $this->assertSame('ap', ThesisCreateController::autofocusFieldForError("Veuillez sélectionner un 'Atelier Pratique'.")); + } + + public function testCreateAutofocusFinality(): void + { + $this->assertSame('finality', ThesisCreateController::autofocusFieldForError("La finalité est manquante.")); + } + + public function testCreateAutofocusLanguages(): void + { + $this->assertSame('languages', ThesisCreateController::autofocusFieldForError('Veuillez sélectionner au moins une langue.')); + } + + public function testCreateAutofocusPromoteur(): void + { + $this->assertSame('jury_promoteur', ThesisCreateController::autofocusFieldForError('Veuillez indiquer au moins un·e promoteur·ice.')); + } + + public function testCreateAutofocusLecteurInterne(): void + { + $this->assertSame('jury_lecteur_interne[]', ThesisCreateController::autofocusFieldForError('Veuillez indiquer un·e lecteur·ice interne.')); + } + + public function testCreateAutofocusLecteurExterne(): void + { + $this->assertSame('jury_lecteur_externe[]', ThesisCreateController::autofocusFieldForError('Veuillez indiquer un·e lecteur·ice externe.')); + } + + public function testCreateAutofocusFormats(): void + { + $this->assertSame('formats', ThesisCreateController::autofocusFieldForError('Veuillez sélectionner au moins un format.')); + } + + public function testCreateAutofocusLicense(): void + { + $this->assertSame('license_id', ThesisCreateController::autofocusFieldForError('Veuillez sélectionner une licence.')); + } + + public function testCreateAutofocusUrl(): void + { + $this->assertSame('lien', ThesisCreateController::autofocusFieldForError('Lien URL invalide.')); + } + + public function testCreateAutofocusTags(): void + { + $this->assertSame('tag', ThesisCreateController::autofocusFieldForError('Veuillez indiquer au moins 3 mots-clés.')); + } + + public function testCreateAutofocusUnknownErrorReturnsNull(): void + { + $this->assertNull(ThesisCreateController::autofocusFieldForError('Some completely unrelated error')); + } + + // ── ThesisEditController::autofocusFieldForError ────────────────────────── + + public function testEditAutofocusTitle(): void + { + $this->assertSame('titre', ThesisEditController::autofocusFieldForError("Le champ 'Titre du TFE' est requis.")); + } + + public function testEditAutofocusYear(): void + { + $this->assertSame('année', ThesisEditController::autofocusFieldForError("L'année est invalide.")); + } + + public function testEditAutofocusSynopsis(): void + { + $this->assertSame('synopsis', ThesisEditController::autofocusFieldForError("Le champ 'Synopsis' est requis.")); + } + + public function testEditAutofocusAuthors(): void + { + $this->assertSame('auteurice', ThesisEditController::autofocusFieldForError("Le champ 'Auteur·ice(s)' est requis.")); + } + + public function testEditAutofocusUnknownErrorReturnsNull(): void + { + $this->assertNull(ThesisEditController::autofocusFieldForError('Some completely unrelated error')); + } + + // ── No field name leak between Create and Edit controllers ──────────────── + + public function testCreateDoesNotLeakEditFieldNames(): void + { + // 'titre' is the Edit controller field name, but Create returns 'titre' too + // Actually check that Create-specific field names like 'auteurice' exist + // and that Edit doesn't return a Create-only name for an unrelated error + + // Create returns 'auteurice' for author errors + $this->assertSame('auteurice', ThesisCreateController::autofocusFieldForError("Le champ 'Auteur·ice(s)' est requis.")); + + // Edit returns 'auteurice' for author errors too (same naming) + $this->assertSame('auteurice', ThesisEditController::autofocusFieldForError("Le champ 'Auteur·ice(s)' est requis.")); + + // Both return null for unknown errors (no spurious field) + $this->assertNull(ThesisCreateController::autofocusFieldForError('bogus error')); + $this->assertNull(ThesisEditController::autofocusFieldForError('bogus error')); + } +} diff --git a/tests/phpunit/DatabaseExtendedTest.php b/tests/phpunit/DatabaseExtendedTest.php new file mode 100644 index 0000000..cbf2e9f --- /dev/null +++ b/tests/phpunit/DatabaseExtendedTest.php @@ -0,0 +1,353 @@ +db = TestDatabase::getInstance(); + } + + // ── escapeLikeString (private, tested via buildSearchConditions) ────────── + + public function testEscapeLikeStringViaSearchConditions(): void + { + // Build conditions with special LIKE characters in query + // Use reflection to call the private method + $ref = new ReflectionMethod(Database::class, 'escapeLikeString'); + $ref->setAccessible(true); + + $this->assertSame('\\\\', $ref->invoke($this->db, '\\')); + $this->assertSame('\\%', $ref->invoke($this->db, '%')); + $this->assertSame('\\_', $ref->invoke($this->db, '_')); + $this->assertSame('hello', $ref->invoke($this->db, 'hello')); + $this->assertSame('50\\% off\\_sale \\\\done', $ref->invoke($this->db, '50% off_sale \\done')); + } + + // ── buildSearchConditions (private, tested via reflection) ──────────────── + + public function testBuildSearchConditionsEmptyParams(): void + { + $ref = new ReflectionMethod(Database::class, 'buildSearchConditions'); + $ref->setAccessible(true); + + [$conditions, $bindings] = $ref->invoke($this->db, []); + + $this->assertCount(1, $conditions); + $this->assertStringContainsString('is_published', $conditions[0]); + $this->assertEmpty($bindings); + } + + public function testBuildSearchConditionsWithQuery(): void + { + $ref = new ReflectionMethod(Database::class, 'buildSearchConditions'); + $ref->setAccessible(true); + + [$conditions, $bindings] = $ref->invoke($this->db, ['query' => 'test']); + + $this->assertGreaterThan(1, count($conditions)); + $this->assertArrayHasKey(':query', $bindings); + $this->assertStringContainsString('%test%', $bindings[':query']); + } + + public function testBuildSearchConditionsWithYear(): void + { + $ref = new ReflectionMethod(Database::class, 'buildSearchConditions'); + $ref->setAccessible(true); + + [$conditions, $bindings] = $ref->invoke($this->db, ['year' => 2024]); + + $this->assertContains('vp.year = :year', $conditions); + $this->assertSame(2024, $bindings[':year']); + } + + public function testBuildSearchConditionsWithAllFilters(): void + { + $ref = new ReflectionMethod(Database::class, 'buildSearchConditions'); + $ref->setAccessible(true); + + [$conditions, $bindings] = $ref->invoke($this->db, [ + 'query' => 'art', + 'year' => 2023, + 'orientation' => 'BD', + 'keyword' => 'design', + 'language' => 'français', + 'format' => 'Vidéo', + ]); + + $this->assertGreaterThan(5, count($conditions)); + $this->assertArrayHasKey(':query', $bindings); + $this->assertArrayHasKey(':year', $bindings); + $this->assertArrayHasKey(':orientation', $bindings); + $this->assertArrayHasKey(':keyword', $bindings); + $this->assertArrayHasKey(':language', $bindings); + $this->assertArrayHasKey(':format', $bindings); + } + + // ── findDuplicateThesis ─────────────────────────────────────────────────── + + public function testFindDuplicateThesisExactMatch(): void + { + [$authorId] = TestDatabase::seedBasicThesis('My Unique Thesis', 'Jane Doe', 2024); + + $dup = $this->db->findDuplicateThesis('My Unique Thesis', ['Jane Doe'], 2024); + $this->assertNotNull($dup); + $this->assertSame(2024, $dup['year']); + $this->assertStringContainsString('My Unique Thesis', $dup['title']); + } + + public function testFindDuplicateThesisMissesDifferentTitle(): void + { + [$authorId] = TestDatabase::seedBasicThesis('Thesis Alpha', 'John Smith', 2024); + + $dup = $this->db->findDuplicateThesis('Completely Different', ['John Smith'], 2024); + $this->assertNull($dup); + } + + public function testFindDuplicateThesisMissesDifferentYear(): void + { + [$authorId] = TestDatabase::seedBasicThesis('Shared Title', 'Alice', 2023); + + $dup = $this->db->findDuplicateThesis('Shared Title', ['Alice'], 2024); + $this->assertNull($dup); + } + + public function testFindDuplicateThesisEmptyAuthorNamesReturnsNull(): void + { + TestDatabase::seedBasicThesis('Test', 'Author', 2024); + + $dup = $this->db->findDuplicateThesis('Test', [], 2024); + $this->assertNull($dup); + } + + public function testFindDuplicateThesisEmptyTable(): void + { + $dup = $this->db->findDuplicateThesis('Anything', ['Anyone'], 2024); + $this->assertNull($dup); + } + + public function testFindDuplicateThesisNearDuplicateByLevenshtein(): void + { + TestDatabase::seedBasicThesis('My Thesis About Art', 'Bob', 2024); + + // One character typo should match via Levenshtein + $dup = $this->db->findDuplicateThesis('My Thesis About Arty', ['Bob'], 2024); + $this->assertNotNull($dup); + } + + // ── generateThesisIdentifier ────────────────────────────────────────────── + + public function testGenerateThesisIdentifierFirstInYear(): void + { + $id = $this->db->generateThesisIdentifier(2025); + $this->assertSame('2025-001', $id); + } + + public function testGenerateThesisIdentifierIncrementsCorrectly(): void + { + // Insert a thesis with identifier 2025-001 + $pdo = TestDatabase::getPDO(); + $pdo->prepare("INSERT INTO theses (title, year, identifier, objet) VALUES (?, ?, ?, 'tfe')") + ->execute(['First', 2025, '2025-001']); + + $id = $this->db->generateThesisIdentifier(2025); + $this->assertSame('2025-002', $id); + } + + public function testGenerateThesisIdentifierUsesMaxNotCount(): void + { + $pdo = TestDatabase::getPDO(); + + // Insert 2025-001, then "delete" it (set deleted_at), insert 2025-005 + $pdo->prepare("INSERT INTO theses (title, year, identifier, objet, deleted_at) VALUES (?, ?, ?, 'tfe', datetime('now'))") + ->execute(['Deleted', 2025, '2025-001']); + $pdo->prepare("INSERT INTO theses (title, year, identifier, objet) VALUES (?, ?, ?, 'tfe')") + ->execute(['Alive', 2025, '2025-005']); + + $id = $this->db->generateThesisIdentifier(2025); + // MAX is 5, so next should be 6 (not 3 from COUNT) + $this->assertSame('2025-006', $id); + } + + // ── getCoverPathsForTheses ──────────────────────────────────────────────── + + public function testGetCoverPathsForThesesReturnsPaths(): void + { + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Cover Test', 'Author', 2024); + + $paths = $this->db->getCoverPathsForTheses([$thesisId]); + + $this->assertArrayHasKey($thesisId, $paths); + $this->assertStringContainsString('cover.jpg', $paths[$thesisId]); + } + + public function testGetCoverPathsForThesesReturnsEmptyForUnknownIds(): void + { + $paths = $this->db->getCoverPathsForTheses([999]); + $this->assertEmpty($paths); + } + + public function testGetCoverPathsForThesesEmptyInputReturnsEmpty(): void + { + $paths = $this->db->getCoverPathsForTheses([]); + $this->assertEmpty($paths); + } + + public function testGetCoverPathsForThesesMultipleTheses(): void + { + [$a1, $t1] = TestDatabase::seedBasicThesis('T1', 'A1', 2024); + [$a2, $t2] = TestDatabase::seedBasicThesis('T2', 'A2', 2025); + + $paths = $this->db->getCoverPathsForTheses([$t1, $t2]); + + $this->assertCount(2, $paths); + $this->assertArrayHasKey($t1, $paths); + $this->assertArrayHasKey($t2, $paths); + } + + // ── findOrCreateAuthor ──────────────────────────────────────────────────── + + public function testFindOrCreateAuthorCreatesNew(): void + { + $id = $this->db->findOrCreateAuthor('New Author'); + $this->assertGreaterThan(0, (int)$id); + } + + public function testFindOrCreateAuthorIdempotent(): void + { + $id1 = $this->db->findOrCreateAuthor('Same Author'); + $id2 = $this->db->findOrCreateAuthor('Same Author'); + + $this->assertEquals($id1, $id2); + } + + public function testFindOrCreateAuthorWithEmail(): void + { + $id1 = $this->db->findOrCreateAuthor('Email Author', 'test@example.com'); + $id2 = $this->db->findOrCreateAuthor('Different Name', 'test@example.com'); + + // Same email → should return the same author ID + $this->assertEquals($id1, $id2); + } + + public function testFindOrCreateAuthorRejectsCSVArtefacts(): void + { + // 'NON' and 'OUI' are treated as null emails + $id1 = $this->db->findOrCreateAuthor('CSV Author', 'NON'); + $id2 = $this->db->findOrCreateAuthor('CSV Author', 'OUI'); + + // Same name, no email → same author + $this->assertEquals($id1, $id2); + } + + // ── Language operations ─────────────────────────────────────────────────── + + public function testDeduplicateLanguagesMergesCaseInsensitiveDupes(): void + { + $pdo = TestDatabase::getPDO(); + + // Count seed languages first (français, anglais, néerlandais, italian) + $seedCount = (int)$pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn(); + + // Insert two languages that differ only by case + $pdo->prepare("INSERT INTO languages (name) VALUES ('TestLang')")->execute(); + $pdo->prepare("INSERT INTO languages (name) VALUES ('testlang')")->execute(); + + // Both seed + 2 new should exist before dedup + $before = $pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn(); + $this->assertSame($seedCount + 2, (int)$before); + + $this->db->deduplicateLanguages(); + + // One of the dupes should be soft-deleted + $after = $pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn(); + $this->assertSame($seedCount + 1, (int)$after); + } + + public function testRenameLanguageUpdatesName(): void + { + $pdo = TestDatabase::getPDO(); + $pdo->prepare("INSERT INTO languages (name) VALUES ('OldName')")->execute(); + $langId = (int)$pdo->lastInsertId(); + + $this->db->renameLanguage($langId, 'NewName'); + + $name = $pdo->query("SELECT name FROM languages WHERE id = $langId")->fetchColumn(); + $this->assertSame('NewName', $name); + } + + public function testMergeLanguageReassignsTheses(): void + { + $pdo = TestDatabase::getPDO(); + + // Create two languages + $pdo->prepare("INSERT INTO languages (name) VALUES ('French')")->execute(); + $frenchId = (int)$pdo->lastInsertId(); + $pdo->prepare("INSERT INTO languages (name) VALUES ('Français')")->execute(); + $francaisId = (int)$pdo->lastInsertId(); + + // Create a thesis linked to 'Français' + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Merge Test', 'Author', 2024); + $pdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)") + ->execute([$thesisId, $francaisId]); + + // Merge Français → French + $this->db->mergeLanguage($francaisId, $frenchId); + + // Check the thesis is now linked to French + $links = $pdo->query("SELECT language_id FROM thesis_languages WHERE thesis_id = $thesisId")->fetchAll(PDO::FETCH_COLUMN); + $this->assertContains($frenchId, array_map('intval', $links)); + $this->assertNotContains($francaisId, array_map('intval', $links)); + + // Source language should be soft-deleted + $deleted = $pdo->query("SELECT deleted_at FROM languages WHERE id = $francaisId")->fetchColumn(); + $this->assertNotNull($deleted); + } + + // ── Tag operations ──────────────────────────────────────────────────────── + + public function testRenameTagUpdatesName(): void + { + $pdo = TestDatabase::getPDO(); + $pdo->prepare("INSERT INTO tags (name) VALUES ('OldTag')")->execute(); + $tagId = (int)$pdo->lastInsertId(); + + $this->db->renameTag($tagId, 'NewTag'); + + $name = $pdo->query("SELECT name FROM tags WHERE id = $tagId")->fetchColumn(); + $this->assertSame('NewTag', $name); + } + + public function testMergeTagReassignsTheses(): void + { + $pdo = TestDatabase::getPDO(); + + $pdo->prepare("INSERT INTO tags (name) VALUES ('TagA')")->execute(); + $tagA = (int)$pdo->lastInsertId(); + $pdo->prepare("INSERT INTO tags (name) VALUES ('TagB')")->execute(); + $tagB = (int)$pdo->lastInsertId(); + + [$authorId, $thesisId] = TestDatabase::seedBasicThesis('Tag Merge', 'Author', 2024); + $pdo->prepare("INSERT INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)") + ->execute([$tagB, $thesisId]); + + $this->db->mergeTag($tagB, $tagA); + + $links = $pdo->query("SELECT tag_id FROM thesis_tags WHERE thesis_id = $thesisId")->fetchAll(PDO::FETCH_COLUMN); + $this->assertContains($tagA, array_map('intval', $links)); + $this->assertNotContains($tagB, array_map('intval', $links)); + + $deleted = $pdo->query("SELECT deleted_at FROM tags WHERE id = $tagB")->fetchColumn(); + $this->assertNotNull($deleted); + } +} diff --git a/tests/phpunit/RateLimitExtendedTest.php b/tests/phpunit/RateLimitExtendedTest.php new file mode 100644 index 0000000..5a33e28 --- /dev/null +++ b/tests/phpunit/RateLimitExtendedTest.php @@ -0,0 +1,156 @@ +tmpDir = sys_get_temp_dir() . '/xamxam_ratelimit_test_' . uniqid(); + mkdir($this->tmpDir, 0755, true); + } + + protected function tearDown(): void + { + $files = glob($this->tmpDir . '/*.json'); + foreach ($files as $file) { + unlink($file); + } + rmdir($this->tmpDir); + } + + private function newRateLimit(int $max = 5, int $window = 60): RateLimit + { + return new RateLimit($max, $window, $this->tmpDir); + } + + // ── checkKey: per-key limits, not global ────────────────────────────────── + + public function testCheckKeyCountsPerKey(): void + { + $rl = $this->newRateLimit(2, 60); + + $this->assertTrue($rl->checkKey('key-a')); + $this->assertTrue($rl->checkKey('key-b')); + $this->assertTrue($rl->checkKey('key-a')); // second hit for key-a, still allowed + + // key-a is now at limit (2) + $this->assertFalse($rl->checkKey('key-a')); + + // key-b still has room + $this->assertTrue($rl->checkKey('key-b')); + $this->assertFalse($rl->checkKey('key-b')); + } + + public function testCheckKeyDoesNotAffectDefaultCheck(): void + { + $rl = $this->newRateLimit(3, 60); + + $rl->checkKey('separate-key'); + $rl->checkKey('separate-key'); + $rl->checkKey('separate-key'); + $rl->checkKey('separate-key'); // exhausted for this key + + // Default check (uses REMOTE_ADDR) should be unaffected by key-based tracking + $ipKey = md5($_SERVER['REMOTE_ADDR'] ?? 'unknown'); + $ipFile = $this->tmpDir . '/' . $ipKey . '.json'; + $this->assertFileDoesNotExist($ipFile); + } + + // ── getRemaining: tied to client IP (REMOTE_ADDR) ───────────────────────── + + public function testGetRemainingStartsAtMax(): void + { + $rl = $this->newRateLimit(5, 60); + + $remaining = $rl->getRemaining(); + $this->assertSame(5, $remaining); + } + + public function testCheckDecrementsRemainingForSameIp(): void + { + $rl = $this->newRateLimit(5, 60); + + // check() uses IP-based identifier. We need to use check() directly, + // not checkKey(), for getRemaining() to reflect usage. + $rl->check(); // hit 1 + $this->assertSame(4, $rl->getRemaining()); + + $rl->check(); // hit 2 + $this->assertSame(3, $rl->getRemaining()); + } + + public function testCheckAndCheckKeyAreIndependent(): void + { + $rl = $this->newRateLimit(5, 60); + + // checkKey hits don't affect IP-based remaining + $rl->checkKey('some-key'); + $rl->checkKey('some-key'); + + $this->assertSame(5, $rl->getRemaining()); + } + + // ── Consistent client identifier ───────────────────────────────────────── + + public function testMultipleChecksFromSameClient(): void + { + $rl = $this->newRateLimit(1, 60); + + // First check passes, second from same IP fails + $this->assertTrue($rl->check()); + $this->assertFalse($rl->check()); + } + + public function testGetRemainingReturnsZeroAfterExhaustion(): void + { + $rl = $this->newRateLimit(1, 60); + + $rl->check(); + $this->assertSame(0, $rl->getRemaining()); + } + + // ── reset time ──────────────────────────────────────────────────────────── + + public function testGetResetTimeReturnsZeroWhenNoData(): void + { + $rl = $this->newRateLimit(5, 60); + $this->assertSame(0, $rl->getResetTime()); + } + + public function testGetResetTimePositiveAfterHits(): void + { + $rl = $this->newRateLimit(5, 60); + $rl->check(); // use IP-based check so file is written + + $reset = $rl->getResetTime(); + $this->assertGreaterThan(0, $reset); + $this->assertLessThanOrEqual(60, $reset); + } + + // ── cleanup ─────────────────────────────────────────────────────────────── + + public function testCleanupRemovesOldFiles(): void + { + $rl = $this->newRateLimit(5, 60); + $rl->checkKey('cleanup-test'); + + // Touch the cache file to make it old + $files = glob($this->tmpDir . '/*.json'); + $this->assertNotEmpty($files); + foreach ($files as $file) { + touch($file, time() - 90000); // 25 hours ago + } + + $rl->cleanup(); + + // The old file should now be gone + $filesAfter = glob($this->tmpDir . '/*.json'); + $this->assertEmpty($filesAfter); + } +} diff --git a/tests/phpunit/ShareLinkExtendedTest.php b/tests/phpunit/ShareLinkExtendedTest.php new file mode 100644 index 0000000..726fd0f --- /dev/null +++ b/tests/phpunit/ShareLinkExtendedTest.php @@ -0,0 +1,204 @@ +shareLink = new ShareLink($db); + $this->pdo = TestDatabase::getPDO(); + } + + /** + * Create a share link and return its row. + */ + private function createLink(?string $name = null, ?string $expiresAt = null, ?string $objetRestriction = null): array + { + $link = $this->shareLink->create(1, $expiresAt, $objetRestriction, $name); + $this->assertNotNull($link); + return $link; + } + + // ── listActive / listArchived ───────────────────────────────────────────── + + public function testListActiveReturnsOnlyActiveLinks(): void + { + $this->createLink('Active One'); + + $active = $this->shareLink->listActive(); + $this->assertCount(1, $active); + + // Archive the link + $link = $this->shareLink->findBySlug($active[0]['slug']); + $this->shareLink->archive($link['id']); + + $active = $this->shareLink->listActive(); + $this->assertCount(0, $active); + } + + public function testListArchivedReturnsOnlyArchivedLinks(): void + { + $this->createLink('To Archive'); + + $archived = $this->shareLink->listArchived(); + $this->assertCount(0, $archived); + + $link = $this->shareLink->findBySlug($this->shareLink->listActive()[0]['slug']); + $this->shareLink->archive($link['id']); + + $archived = $this->shareLink->listArchived(); + $this->assertCount(1, $archived); + } + + // ── findBySlug ──────────────────────────────────────────────────────────── + + public function testFindBySlugHit(): void + { + $created = $this->createLink('Find Me'); + $found = $this->shareLink->findBySlug($created['slug']); + + $this->assertNotNull($found); + $this->assertSame($created['id'], $found['id']); + $this->assertSame('Find Me', $found['name']); + } + + public function testFindBySlugMiss(): void + { + $found = $this->shareLink->findBySlug('nonexistent-slug'); + $this->assertNull($found); + } + + // ── setPassword + getDecryptedPassword round-trip ───────────────────────── + + public function testSetPasswordAndDecryptRoundTrip(): void + { + $created = $this->createLink('Password Test'); + + // getDecryptedPassword uses encrypted_password from the DB, not _plain_password + $decrypted = $this->shareLink->getDecryptedPassword($created['id']); + $this->assertNotEmpty($decrypted); + } + + public function testGetDecryptedPasswordOnNonexistentId(): void + { + $result = $this->shareLink->getDecryptedPassword(999); + $this->assertSame('', $result); + } + + // ── update ──────────────────────────────────────────────────────────────── + + public function testUpdateChangesNameAndExpiration(): void + { + $created = $this->createLink('Original Name'); + + $this->shareLink->update($created['id'], 'Updated Name', '2099-12-31 23:59:59'); + + $updated = $this->shareLink->findBySlug($created['slug']); + $this->assertSame('Updated Name', $updated['name']); + $this->assertNotNull($updated['expires_at']); + } + + public function testUpdateOnlyNameLeavesExpirationUnchanged(): void + { + $created = $this->createLink('Original', '2099-12-31 23:59:59'); + + $this->shareLink->update($created['id'], 'Only Name Changed', null); + + $updated = $this->shareLink->findBySlug($created['slug']); + $this->assertSame('Only Name Changed', $updated['name']); + $this->assertNotNull($updated['expires_at']); + } + + public function testUpdateClearsExpiration(): void + { + $created = $this->createLink('Expiry Test', '2099-12-31 23:59:59'); + + // Pass empty string to clear + $this->shareLink->update($created['id'], null, ''); + + $updated = $this->shareLink->findBySlug($created['slug']); + $this->assertNull($updated['expires_at']); + } + + // ── locked_year ─────────────────────────────────────────────────────────── + + public function testCreateWithLockedYear(): void + { + $created = $this->shareLink->create(1, null, null, 'Locked Year Link', 2025); + + $found = $this->shareLink->findBySlug($created['slug']); + $this->assertSame(2025, $found['locked_year']); + } + + public function testCreateWithInvalidLockedYearRejected(): void + { + $created = $this->shareLink->create(1, null, null, 'Bad Year Link', 1800); + + $found = $this->shareLink->findBySlug($created['slug']); + $this->assertNull($found['locked_year']); + } + + public function testUpdateLockedYear(): void + { + $created = $this->createLink('Year Upd'); + + $this->shareLink->update($created['id'], null, null, '2026'); + + $updated = $this->shareLink->findBySlug($created['slug']); + $this->assertSame(2026, $updated['locked_year']); + } + + public function testUpdateClearLockedYear(): void + { + $created = $this->shareLink->create(1, null, null, 'Year Clear', 2025); + + // Empty string clears + $this->shareLink->update($created['id'], null, null, ''); + + $updated = $this->shareLink->findBySlug($created['slug']); + $this->assertNull($updated['locked_year']); + } + + // ── incrementUsage ──────────────────────────────────────────────────────── + + public function testIncrementUsage(): void + { + $created = $this->createLink('Usage Test'); + + $found = $this->shareLink->findBySlug($created['slug']); + $this->assertSame(0, (int)$found['usage_count']); + + $this->shareLink->incrementUsage($created['id']); + $this->shareLink->incrementUsage($created['id']); + + $found = $this->shareLink->findBySlug($created['slug']); + $this->assertSame(2, (int)$found['usage_count']); + } + + // ── Objet restriction validation ────────────────────────────────────────── + + public function testCreateDefaultsToTfeWhenInvalidObjet(): void + { + $created = $this->shareLink->create(1, null, 'invalid_objet', 'Invalid Objet'); + + $found = $this->shareLink->findBySlug($created['slug']); + $this->assertSame('tfe', $found['objet_restriction']); + } + + public function testCreateAcceptsValidObjet(): void + { + $created = $this->shareLink->create(1, null, 'thèse,frart', 'Multi Objet'); + + $found = $this->shareLink->findBySlug($created['slug']); + $this->assertSame('thèse,frart', $found['objet_restriction']); + } +} diff --git a/tests/phpunit/ThesisCreateValidationTest.php b/tests/phpunit/ThesisCreateValidationTest.php new file mode 100644 index 0000000..da42b4a --- /dev/null +++ b/tests/phpunit/ThesisCreateValidationTest.php @@ -0,0 +1,341 @@ +pdo = TestDatabase::getPDO(); + } + + /** + * Invoke the private validateAndSanitise() method via reflection. + */ + private function validate(array $post, bool $adminMode = false): array + { + $ref = new ReflectionMethod(ThesisCreateController::class, 'validateAndSanitise'); + $ref->setAccessible(true); + + $db = TestDatabase::getInstance(); + $ctrl = new ThesisCreateController($db); + + return $ref->invoke($ctrl, $post, $adminMode); + } + + /** + * Build minimal valid POST data for a submission. + */ + private function validPost(): array + { + return [ + 'auteurice' => 'John Doe', + 'année' => '2024', + 'orientation' => '1', + 'ap' => '1', + 'finality' => '1', + 'titre' => 'My Thesis Title', + 'synopsis' => 'A compelling synopsis.', + 'jury_promoteur' => ['Promoteur One'], + 'jury_lecteur_interne' => ['Lecteur Interne'], + 'jury_lecteur_externe' => ['Lecteur Externe'], + 'tag' => ['art', 'design', 'research'], + 'languages' => ['1'], + 'formats' => ['2'], + 'access_type_id' => '1', + 'license_id' => '1', + 'objet' => 'tfe', + ]; + } + + // ── Valid submission ───────────────────────────────────────────────────── + + public function testValidSubmissionReturnsCleanedData(): void + { + $data = $this->validate($this->validPost()); + + $this->assertSame('My Thesis Title', $data['titre']); + $this->assertSame(2024, $data['annee']); + $this->assertSame('John Doe', $data['authorNames'][0]); + $this->assertNotEmpty($data['juryMembers']); + $this->assertCount(3, $data['keywords']); + } + + // ── Missing required fields ────────────────────────────────────────────── + + public function testMissingTitleThrowsException(): void + { + $post = $this->validPost(); + $post['titre'] = ''; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Titre du TFE'); + + $this->validate($post); + } + + public function testMissingAuthorsThrowsException(): void + { + $post = $this->validPost(); + $post['auteurice'] = ''; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Auteur'); + + $this->validate($post); + } + + public function testMissingSynopsisThrowsException(): void + { + $post = $this->validPost(); + $post['synopsis'] = ''; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Synopsis'); + + $this->validate($post); + } + + public function testMissingOrientationInNonAdminModeThrowsException(): void + { + $post = $this->validPost(); + $post['orientation'] = ''; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('orientation'); + + $this->validate($post); + } + + public function testMissingAPProgramInNonAdminModeThrowsException(): void + { + $post = $this->validPost(); + $post['ap'] = ''; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Atelier Pratique'); + + $this->validate($post); + } + + public function testMissingFinalityInNonAdminModeThrowsException(): void + { + $post = $this->validPost(); + $post['finality'] = ''; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('finalité'); + + $this->validate($post); + } + + // ── Invalid year ───────────────────────────────────────────────────────── + + public function testInvalidYearFormatRejected(): void + { + $post = $this->validPost(); + $post['année'] = 'not-a-year'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Année invalide'); + + $this->validate($post); + } + + public function testYearZeroRejected(): void + { + $post = $this->validPost(); + $post['année'] = '0'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Année invalide'); + + $this->validate($post); + } + + public function testYearBefore2000Rejected(): void + { + $post = $this->validPost(); + $post['année'] = '1999'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Année invalide'); + + $this->validate($post); + } + + public function testFarFutureYearRejected(): void + { + $post = $this->validPost(); + $post['année'] = (string)((int)date('Y') + 5); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Année invalide'); + + $this->validate($post); + } + + public function testCurrentYearAccepted(): void + { + $post = $this->validPost(); + $post['année'] = (string)(int)date('Y'); + + $data = $this->validate($post); + $this->assertSame((int)date('Y'), $data['annee']); + } + + // ── Malformed URL ──────────────────────────────────────────────────────── + + public function testMalformedUrlRejected(): void + { + $post = $this->validPost(); + $post['lien'] = 'not-a-valid-url'; + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Lien URL invalide'); + + $this->validate($post); + } + + public function testValidUrlAccepted(): void + { + $post = $this->validPost(); + $post['lien'] = 'https://example.com'; + + $data = $this->validate($post); + $this->assertSame('https://example.com', $data['lien']); + } + + // ── Tag deduplication ──────────────────────────────────────────────────── + + public function testDuplicateTagsAreDeduplicated(): void + { + $post = $this->validPost(); + $post['tag'] = ['art', 'Art', 'ART', 'design', 'research', 'philosophy']; + + $data = $this->validate($post); + // Tags are lowercased and deduplicated: art×3 + design + research + philosophy = 4 + $this->assertCount(4, $data['keywords']); + $expected = ['art', 'design', 'philosophy', 'research']; + sort($data['keywords']); + $this->assertSame($expected, $data['keywords']); + } + + public function testMaxTenKeywordsEnforced(): void + { + $post = $this->validPost(); + $post['tag'] = range(1, 12); + + $data = $this->validate($post); + $this->assertCount(10, $data['keywords']); + } + + // ── XSS escaping ───────────────────────────────────────────────────────── + + public function testXssPayloadStrippedFromTitle(): void + { + $post = $this->validPost(); + $post['titre'] = 'Clean Title'; + + $data = $this->validate($post); + $this->assertStringNotContainsString('