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('