mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
Add integration tests (Phase 2: DatabaseExtended, ShareLinkExtended, RateLimitExtended) and controller validation tests (Phase 3: ThesisCreate, ThesisEdit, AutofocusField)
This commit is contained in:
File diff suppressed because one or more lines are too long
14
TODO.md
14
TODO.md
@@ -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
142
tests/TestDatabase.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
138
tests/phpunit/AutofocusFieldForErrorTest.php
Normal file
138
tests/phpunit/AutofocusFieldForErrorTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
353
tests/phpunit/DatabaseExtendedTest.php
Normal file
353
tests/phpunit/DatabaseExtendedTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
tests/phpunit/RateLimitExtendedTest.php
Normal file
156
tests/phpunit/RateLimitExtendedTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
tests/phpunit/ShareLinkExtendedTest.php
Normal file
204
tests/phpunit/ShareLinkExtendedTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
341
tests/phpunit/ThesisCreateValidationTest.php
Normal file
341
tests/phpunit/ThesisCreateValidationTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
tests/phpunit/ThesisEditValidationTest.php
Normal file
198
tests/phpunit/ThesisEditValidationTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user