Add integration tests (Phase 2: DatabaseExtended, ShareLinkExtended, RateLimitExtended) and controller validation tests (Phase 3: ThesisCreate, ThesisEdit, AutofocusField)

This commit is contained in:
Pontoporeia
2026-05-20 01:51:41 +02:00
parent 7a4d0fafb2
commit 93625d09b5
10 changed files with 1543 additions and 8 deletions

File diff suppressed because one or more lines are too long

14
TODO.md
View File

@@ -14,15 +14,15 @@
- [x] 1.5 `TfeControllerOgTest.php` — buildOgTags: required keys, image fallback, description truncation - [x] 1.5 `TfeControllerOgTest.php` — buildOgTags: required keys, image fallback, description truncation
## Phase 2 — Integration (requires test database) ## Phase 2 — Integration (requires test database)
- [ ] 2.0 Setup: `tests/fixtures/`, TestDatabase helper, `.env.test` - [x] 2.0 Setup: `tests/fixtures/`, TestDatabase helper, `.env.test`
- [ ] 2.1 `DatabaseExtendedTest.php` — escapeLikeString, buildSearchConditions, findDuplicateThesis, generateThesisIdentifier, getCoverPathsForTheses, findOrCreateAuthor, deduplicateLanguages/renameLanguage/mergeLanguage, renameTag/mergeTag - [x] 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 - [x] 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.3 `RateLimitExtendedTest.php` — checkKey per-key counts, getRemaining decrement, getClientIdentifier consistency
## Phase 3 — Controller Validation ## Phase 3 — Controller Validation
- [ ] 3.1 `ThesisCreateValidationTest.php` — valid submission, missing required fields, invalid year, malformed URL, tag dedup, XSS escaping - [x] 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 - [x] 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.3 `AutofocusFieldForErrorTest.php` — correct field per error key, unknown key returns null/default, no CreateController name leak
## Phase 4 — Cleanup ## Phase 4 — Cleanup
- [ ] 4.1 Migrate 8 existing custom-runner tests to PHPUnit in `tests/phpunit/` - [ ] 4.1 Migrate 8 existing custom-runner tests to PHPUnit in `tests/phpunit/`

142
tests/TestDatabase.php Normal file
View File

@@ -0,0 +1,142 @@
<?php
/**
* TestDatabase — helper for integration tests that need a real SQLite database.
*
* Creates an in-memory SQLite database, loads the full schema + seed data,
* and provides a Database instance connected to it. Teardown discards the DB.
*/
/**
* Thin Database subclass that accepts a pre-built PDO connection.
* Bypasses the normal path-based constructor.
*/
class TestDatabaseInstance extends Database
{
public function __construct(PDO $pdo)
{
// Inject PDO directly via reflection, then flag as ready
$ref = new ReflectionProperty(Database::class, 'pdo');
$ref->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];
}
}

View File

@@ -15,3 +15,6 @@ define('APP_ROOT', realpath(__DIR__ . '/../app'));
// Storage directory for tests — use app/storage/ // Storage directory for tests — use app/storage/
define('STORAGE_ROOT', APP_ROOT . '/storage'); define('STORAGE_ROOT', APP_ROOT . '/storage');
// Test helpers
require_once __DIR__ . '/TestDatabase.php';

View File

@@ -0,0 +1,138 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* AutofocusFieldForErrorTest — Tests for the autofocusFieldForError helpers
* on both ThesisCreateController and ThesisEditController.
*/
class AutofocusFieldForErrorTest extends TestCase
{
// ── ThesisCreateController::autofocusFieldForError ────────────────────────
public function testCreateAutofocusTitle(): void
{
$this->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'));
}
}

View File

@@ -0,0 +1,353 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* DatabaseExtendedTest — Integration tests for Database methods that require
* a real SQLite DB but are pure-logic-like in nature.
*
* Each test resets data via TestDatabase::resetData() then seeds what it needs.
*/
class DatabaseExtendedTest extends TestCase
{
private Database $db;
protected function setUp(): void
{
TestDatabase::resetData();
$this->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);
}
}

View File

@@ -0,0 +1,156 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* RateLimitExtendedTest — Integration tests for RateLimit using a temp directory.
*/
class RateLimitExtendedTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,204 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* ShareLinkExtendedTest — Integration tests for ShareLink methods that need a DB.
*/
class ShareLinkExtendedTest extends TestCase
{
private ShareLink $shareLink;
private PDO $pdo;
protected function setUp(): void
{
TestDatabase::resetData();
$db = TestDatabase::getInstance();
$this->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']);
}
}

View File

@@ -0,0 +1,341 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* ThesisCreateValidationTest — Tests for ThesisCreateController::validateAndSanitise()
* and related validation logic.
*
* validateAndSanitise() is private; we test via a thin subclass or by driving
* the submit() method with controlled inputs. For pure validation tests,
* we use reflection.
*/
class ThesisCreateValidationTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
TestDatabase::resetData();
$this->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'] = '<script>alert("xss")</script>Clean Title';
$data = $this->validate($post);
$this->assertStringNotContainsString('<script>', $data['titre']);
$this->assertStringContainsString('Clean Title', $data['titre']);
}
public function testHtmlInSynopsisStripped(): void
{
$post = $this->validPost();
$post['synopsis'] = '<b>Bold</b> synopsis <img src=x onerror=alert(1)>';
$data = $this->validate($post);
$this->assertStringNotContainsString('<b>', $data['synopsis']);
$this->assertStringNotContainsString('<img', $data['synopsis']);
$this->assertStringContainsString('Bold synopsis', $data['synopsis']);
}
// ── Author processing ────────────────────────────────────────────────────
public function testMultipleAuthorsAreSorted(): void
{
$post = $this->validPost();
$post['auteurice'] = 'Zoe, Alice, Bob';
$data = $this->validate($post);
$this->assertSame(['Alice', 'Bob', 'Zoe'], $data['authorNames']);
}
// ── Jury validation ──────────────────────────────────────────────────────
public function testMissingPromoteurInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
$post['jury_promoteur'] = [];
$this->expectException(Exception::class);
$this->expectExceptionMessage('promoteur');
$this->validate($post);
}
public function testMissingLecteurInterneInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
$post['jury_lecteur_interne'] = [];
$this->expectException(Exception::class);
$this->expectExceptionMessage('lecteur·ice interne');
$this->validate($post);
}
// ── Language validation ──────────────────────────────────────────────────
public function testMissingLanguagesInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
unset($post['languages']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('langue');
$this->validate($post);
}
// ── Format validation ────────────────────────────────────────────────────
public function testMissingFormatsInNonAdminModeThrowsException(): void
{
$post = $this->validPost();
unset($post['formats']);
$this->expectException(Exception::class);
$this->expectExceptionMessage('format');
$this->validate($post);
}
// ── License validation ───────────────────────────────────────────────────
public function testMissingLicenseWithLibreAccessThrowsException(): void
{
$post = $this->validPost();
$post['access_type_id'] = '1';
$post['license_id'] = '';
$this->expectException(Exception::class);
$this->expectExceptionMessage('licence');
$this->validate($post);
}
}

View File

@@ -0,0 +1,198 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* ThesisEditValidationTest — Tests for ThesisEditController validation helpers
* (collectJuryMembers, handleWebsiteUrl, load).
*/
class ThesisEditValidationTest extends TestCase
{
private PDO $pdo;
private ThesisEditController $ctrl;
protected function setUp(): void
{
TestDatabase::resetData();
$this->pdo = TestDatabase::getPDO();
$db = TestDatabase::getInstance();
$this->ctrl = new ThesisEditController($db);
}
// ── load() ───────────────────────────────────────────────────────────────
public function testLoadReturnsDataForKnownId(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Load Test', 'Author Name', 2024);
$data = $this->ctrl->load($thesisId);
$this->assertIsArray($data);
$this->assertArrayHasKey('thesis', $data);
$this->assertSame('Load Test', $data['thesis']['title']);
$this->assertArrayHasKey('orientations', $data);
$this->assertArrayHasKey('formatTypes', $data);
}
public function testLoadThrowsOnUnknownId(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('TFE non trouvé');
$this->ctrl->load(9999);
}
public function testLoadThrowsOnInvalidId(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('ID invalide');
$this->ctrl->load(0);
}
public function testLoadThrowsOnNegativeId(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('ID invalide');
$this->ctrl->load(-5);
}
// ── collectJuryMembers (private, test via reflection) ─────────────────────
private function collectJuryMembers(array $post): array
{
$ref = new ReflectionMethod(ThesisEditController::class, 'collectJuryMembers');
$ref->setAccessible(true);
return $ref->invoke($this->ctrl, $post);
}
public function testCollectJuryMembersEmptyInput(): void
{
$members = $this->collectJuryMembers([]);
$this->assertIsArray($members);
$this->assertEmpty($members);
}
public function testCollectJuryMembersSinglePromoteur(): void
{
$post = ['jury_promoteur' => ['John Smith']];
$members = $this->collectJuryMembers($post);
$this->assertCount(1, $members);
$this->assertSame('promoteur', $members[0]['role']);
$this->assertSame('John Smith', $members[0]['name']);
$this->assertSame(0, $members[0]['is_external']);
$this->assertSame(0, $members[0]['is_ulb']);
}
public function testCollectJuryMembersPromoteurUlb(): void
{
$post = ['jury_promoteur_ulb_name' => ['ULB Prof']];
$members = $this->collectJuryMembers($post);
$this->assertCount(1, $members);
$this->assertSame('promoteur', $members[0]['role']);
$this->assertSame(1, $members[0]['is_external']);
$this->assertSame(1, $members[0]['is_ulb']);
}
public function testCollectJuryMembersLecteurs(): void
{
$post = [
'jury_lecteur_interne' => ['Int One', 'Int Two'],
'jury_lecteur_externe' => ['Ext One'],
];
$members = $this->collectJuryMembers($post);
$this->assertCount(3, $members);
$internes = array_filter($members, fn($m) => $m['is_external'] === 0 && $m['role'] === 'lecteur');
$externes = array_filter($members, fn($m) => $m['is_external'] === 1 && $m['role'] === 'lecteur');
$this->assertCount(2, $internes);
$this->assertCount(1, $externes);
}
public function testCollectJuryMembersDeduplicatesEmptyStrings(): void
{
$post = [
'jury_promoteur' => ['John', '', ' ', 'Jane'],
];
$members = $this->collectJuryMembers($post);
$names = array_column($members, 'name');
$this->assertCount(2, $names);
$this->assertContains('John', $names);
$this->assertContains('Jane', $names);
}
public function testCollectJuryMembersScalarPromoteurAccepted(): void
{
// Accepts scalar instead of array for promoteur fields
$post = ['jury_promoteur' => 'Single Promoter'];
$members = $this->collectJuryMembers($post);
$this->assertCount(1, $members);
$this->assertSame('Single Promoter', $members[0]['name']);
}
// ── handleWebsiteUrl (private, test via reflection) ───────────────────────
private function invokeHandleWebsiteUrl(int $thesisId, array $post): void
{
$ref = new ReflectionMethod(ThesisEditController::class, 'handleWebsiteUrl');
$ref->setAccessible(true);
$ref->invoke($this->ctrl, $thesisId, $post);
}
public function testHandleWebsiteUrlStoresValidUrl(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Website Test', 'Author', 2024);
$post = ['website_url' => 'https://example.com', 'website_label' => 'My Site'];
$this->invokeHandleWebsiteUrl($thesisId, $post);
$pdo = TestDatabase::getPDO();
$files = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetchAll();
$this->assertCount(1, $files);
$this->assertSame('https://example.com', $files[0]['file_path']);
$this->assertSame('My Site', $files[0]['display_label']);
}
public function testHandleWebsiteUrlSkipsInvalidUrl(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Bad URL Test', 'Author', 2024);
$post = ['website_url' => 'not-a-url'];
$this->invokeHandleWebsiteUrl($thesisId, $post);
$pdo = TestDatabase::getPDO();
$count = $pdo->query("SELECT COUNT(*) FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetchColumn();
$this->assertSame(0, (int)$count);
}
public function testHandleWebsiteUrlSkipsEmptyUrl(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Empty URL', 'Author', 2024);
$post = ['website_url' => ''];
$this->invokeHandleWebsiteUrl($thesisId, $post);
$pdo = TestDatabase::getPDO();
$count = $pdo->query("SELECT COUNT(*) FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetchColumn();
$this->assertSame(0, (int)$count);
}
public function testHandleWebsiteUrlNormalisesHttp(): void
{
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('HTTP URL Test', 'Author', 2024);
$post = ['website_url' => 'https://example.com/path'];
$this->invokeHandleWebsiteUrl($thesisId, $post);
$pdo = TestDatabase::getPDO();
$file = $pdo->query("SELECT * FROM thesis_files WHERE thesis_id = $thesisId AND file_type = 'website'")->fetch();
$this->assertStringContainsString('example.com/path', $file['file_name']);
}
}