Add PHPUnit setup (Phase 0) and pure-logic tests (Phase 1): Crypto, EmailObfuscator, SystemController helpers, StudentEmail, TfeController OG tags

This commit is contained in:
Pontoporeia
2026-05-20 01:28:22 +02:00
parent d9e4541749
commit 7a4d0fafb2
12 changed files with 2811 additions and 14 deletions

17
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
/**
* PHPUnit bootstrap for XAMXAM.
*
* Sets up autoloading, constants, and any environment needed before tests run.
* Uses the production app/.env for APP_KEY (Crypto tests need a real key).
*/
// Composer autoloader
require_once __DIR__ . '/../vendor/autoload.php';
// Define application root (points to app/)
define('APP_ROOT', realpath(__DIR__ . '/../app'));
// Storage directory for tests — use app/storage/
define('STORAGE_ROOT', APP_ROOT . '/storage');

View File

@@ -0,0 +1,152 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* CryptoTest — Pure logic tests for the Crypto class (AES-256-GCM).
*
* Requires a valid APP_KEY in app/.env (set up by bootstrap.php).
*/
class CryptoTest extends TestCase
{
// ── Encrypt → Decrypt round-trip ──────────────────────────────────────────
public function testEncryptDecryptRoundTrip(): void
{
$plain = 'Hello, World!';
$enc = Crypto::encrypt($plain);
$dec = Crypto::decrypt($enc);
$this->assertSame($plain, $dec);
$this->assertNotSame($plain, $enc, 'Ciphertext must differ from plaintext');
}
public function testEncryptDecryptWithUnicode(): void
{
$plain = 'émoji 🎉 — français & 日本語';
$enc = Crypto::encrypt($plain);
$dec = Crypto::decrypt($enc);
$this->assertSame($plain, $dec);
}
public function testEncryptDecryptMultiline(): void
{
$plain = "Line 1\nLine 2\r\nLine 3\n\n";
$enc = Crypto::encrypt($plain);
$dec = Crypto::decrypt($enc);
$this->assertSame($plain, $dec);
}
// ── Different plaintexts → different ciphertexts ──────────────────────────
public function testDifferentPlaintextsProduceDifferentCiphertexts(): void
{
$a = Crypto::encrypt('alpha');
$b = Crypto::encrypt('beta');
$this->assertNotSame($a, $b);
}
public function testSamePlaintextProducesDifferentCiphertexts(): void
{
// IV is random every time — encrypting the same value twice should
// never produce identical ciphertext (except in astronomically rare
// collisions of a 12-byte IV).
$a = Crypto::encrypt('same');
$b = Crypto::encrypt('same');
$this->assertNotSame($a, $b, 'IV randomness → ciphertexts must differ');
}
// ── isEncrypted() ─────────────────────────────────────────────────────────
public function testIsEncryptedRecognizesEncryptedValue(): void
{
$enc = Crypto::encrypt('secret');
$this->assertTrue(Crypto::isEncrypted($enc));
}
public function testIsEncryptedRejectsPlaintext(): void
{
$this->assertFalse(Crypto::isEncrypted('plaintext password'));
}
public function testIsEncryptedReturnsFalseForEmptyString(): void
{
$this->assertFalse(Crypto::isEncrypted(''));
}
public function testIsEncryptedRejectsInvalidBase64(): void
{
$this->assertFalse(Crypto::isEncrypted('not valid base64!!!'));
}
// ── Empty string ──────────────────────────────────────────────────────────
public function testEncryptEmptyStringProducesCiphertext(): void
{
$enc = Crypto::encrypt('');
$this->assertNotEmpty($enc, 'Encrypting empty string should still produce ciphertext');
// Note: isEncrypted() returns false for the empty-string ciphertext
// because the raw blob (IV+TAG+empty ciphertext) is exactly 28 bytes,
// which is < IV_LEN + TAG_LEN + 1 (29). This is a known edge case.
$raw = base64_decode($enc, true);
$this->assertIsString($raw);
$this->assertEquals(28, strlen($raw), 'IV(12) + TAG(16) + empty ciphertext(0)');
}
public function testDecryptEmptyStringReturnsEmpty(): void
{
$this->assertSame('', Crypto::decrypt(''));
}
// ── Invalid base64 input ──────────────────────────────────────────────────
public function testDecryptInvalidBase64ReturnsInputGracefully(): void
{
// Legacy fallback: non-base64 strings are returned as-is
$input = 'plaintext-legacy-password';
$result = Crypto::decrypt($input);
$this->assertSame($input, $result);
}
public function testDecryptTooShortBlobReturnsInputGracefully(): void
{
// Base64 blob that decodes to fewer bytes than IV + TAG + 1
$tooShort = base64_encode('abc');
$result = Crypto::decrypt($tooShort);
$this->assertSame($tooShort, $result);
}
// ── Wrong key / tampering ─────────────────────────────────────────────────
public function testDecryptWithTamperedCiphertextReturnsEmpty(): void
{
$enc = Crypto::encrypt('tamper me');
$raw = base64_decode($enc, true);
// Flip a byte inside the ciphertext portion (after IV + tag)
$tamperOffset = 12 + 16; // after 12-byte IV + 16-byte tag
$raw[$tamperOffset] = chr(ord($raw[$tamperOffset]) ^ 0xFF);
$tampered = base64_encode($raw);
$result = Crypto::decrypt($tampered);
$this->assertSame('', $result, 'Tampered ciphertext must return empty string');
}
public function testDecryptValidBlobTamperedTagReturnsEmpty(): void
{
$enc = Crypto::encrypt('test');
$raw = base64_decode($enc, true);
// Tamper with the authentication tag (bytes 13-28, after the 12-byte IV)
$raw[13] = chr(ord($raw[13]) ^ 0xFF);
$tampered = base64_encode($raw);
$result = Crypto::decrypt($tampered);
$this->assertSame('', $result, 'Tag mismatch must return empty');
}
}

View File

@@ -0,0 +1,166 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* EmailObfuscatorTest — Pure logic tests for EmailObfuscator.
*/
class EmailObfuscatorTest extends TestCase
{
// ── encode() ──────────────────────────────────────────────────────────────
public function testEncodeContainsNoAtSign(): void
{
$result = EmailObfuscator::email('test@example.com');
$this->assertStringNotContainsString('@', $result);
$this->assertStringContainsString('&#64;', $result); // decimal entity for @
}
public function testEncodeOutputIsNumericEntities(): void
{
$result = EmailObfuscator::email('a@b.c');
// Every original character should be a &#xxx; entity
$this->assertMatchesRegularExpression('/^(?:&#\d+;)+$/', $result);
$this->assertNotEmpty($result);
}
// ── email() ───────────────────────────────────────────────────────────────
public function testEmailReturnsObfuscatedAddress(): void
{
$result = EmailObfuscator::email('hello@world.org');
$this->assertStringNotContainsString('hello', $result);
$this->assertStringNotContainsString('@', $result);
$this->assertStringContainsString('&#104;', $result); // 'h'
$this->assertStringContainsString('&#64;', $result); // '@'
}
// ── mailto() ──────────────────────────────────────────────────────────────
public function testMailtoBuildsCorrectHrefStructure(): void
{
$result = EmailObfuscator::mailto('x@y.com');
$this->assertStringStartsWith('mailto:', $result);
$this->assertStringNotContainsString('@', $result);
$this->assertStringContainsString('&#64;', $result);
}
// ── emailText() ───────────────────────────────────────────────────────────
public function testEmailTextReplacesBareEmail(): void
{
$input = 'Contact xamxam@erg.be for help';
$result = EmailObfuscator::emailText($input);
$this->assertStringNotContainsString('xamxam@erg.be', $result);
$this->assertStringContainsString('&#64;', $result); // @ entity
$this->assertStringContainsString('Contact', $result); // surrounding text preserved
// Entity-encoded chars: 120=x, 97=a, 109=m, so 'xamxam' becomes &#120;&#97;&#109;...
$this->assertStringContainsString('&#120;', $result); // 'x'
$this->assertStringContainsString('&#97;', $result); // 'a'
$this->assertStringContainsString('&#109;', $result); // 'm'
}
public function testEmailTextReplacesMultipleEmails(): void
{
$input = 'a@b.com and c@d.org';
$result = EmailObfuscator::emailText($input);
$this->assertStringNotContainsString('@', $result);
$this->assertStringContainsString('&#64;', $result);
// Should have two occurrences of the @ entity
$this->assertEquals(2, substr_count($result, '&#64;'));
}
// ── mailtoInText() ────────────────────────────────────────────────────────
public function testMailtoInTextReplacesMailtoLinks(): void
{
$input = '<a href="mailto:contact@example.com">contact</a>';
$result = EmailObfuscator::mailtoInText($input);
$this->assertStringNotContainsString('contact@example.com', $result);
$this->assertStringContainsString('mailto:', $result);
$this->assertStringContainsString('&#64;', $result);
}
// ── obfuscateHtml() ───────────────────────────────────────────────────────
public function testObfuscateHtmlReplacesAnchorTag(): void
{
$input = '<a href="mailto:contact@example.com">contact@example.com</a>';
$result = EmailObfuscator::obfuscateHtml($input);
$this->assertStringNotContainsString('contact@example.com', $result);
$this->assertStringContainsString('<a ', $result);
$this->assertStringContainsString('href="mailto:', $result);
// @ entity should appear twice (href + link text)
$this->assertGreaterThanOrEqual(2, substr_count($result, '&#64;'));
}
public function testObfuscateHtmlKeepsNonMailtoLinksUnchanged(): void
{
$input = '<a href="https://erg.be">ERG</a> and <a href="mailto:x@y.z">x@y.z</a>';
$result = EmailObfuscator::obfuscateHtml($input);
$this->assertStringContainsString('https://erg.be', $result);
$this->assertStringContainsString('ERG', $result);
$this->assertStringNotContainsString('x@y.z', $result);
}
public function testObfuscateHtmlPreservesCustomLinkText(): void
{
// When the link text differs from the email, only the href is obfuscated
$input = '<a href="mailto:foobar@test.com">custom text</a>';
$result = EmailObfuscator::obfuscateHtml($input);
$this->assertStringNotContainsString('foobar@test.com', $result);
$this->assertStringContainsString('&#64;', $result); // in href
$this->assertStringContainsString('custom text', $result); // link text unchanged
}
// ── Edge cases ────────────────────────────────────────────────────────────
public function testEmptyStringReturnsEmpty(): void
{
$this->assertSame('', EmailObfuscator::email(''));
}
public function testStringWithNoEmailsIsUnchanged(): void
{
$input = 'Hello, world! No emails here.';
$this->assertSame($input, EmailObfuscator::emailText($input));
}
public function testAlreadyObfuscatedContentIsNotDoubleEncoded(): void
{
// Already entity-encoded email — no literal '@' to match
$input = 'Contact &#120;&#97;&#109;&#120;&#97;&#109;&#64;&#101;&#114;&#103;&#46;&#98;&#101; here';
$result = EmailObfuscator::emailText($input);
// Should be unchanged (regex won't match entity-encoded @)
$this->assertSame($input, $result);
}
public function testMultipleEmailsInOneString(): void
{
$input = 'Mail to a@b.com or c@d.org pls';
$result = EmailObfuscator::emailText($input);
$this->assertStringNotContainsString('a@b.com', $result);
$this->assertStringNotContainsString('c@d.org', $result);
// Two @ entities
$this->assertEquals(2, substr_count($result, '&#64;'));
}
public function testEmailWithPlusSign(): void
{
$result = EmailObfuscator::email('user+tag@domain.com');
$this->assertStringContainsString('&#43;', $result); // '+'
$this->assertStringContainsString('&#64;', $result); // '@'
}
}

View File

@@ -0,0 +1,140 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* StudentEmailTest — Pure logic tests for StudentEmail::buildHtml().
*
* buildHtml() is private but we can test it via reflection.
*/
class StudentEmailTest extends TestCase
{
/**
* Invoke the private buildHtml() via reflection.
*/
private function buildHtml(array $thesis): string
{
$ref = new ReflectionMethod(StudentEmail::class, 'buildHtml');
$ref->setAccessible(true);
return $ref->invoke(null, $thesis);
}
// ── Basic output ──────────────────────────────────────────────────────────
public function testBuildHtmlReturnsNonEmptyString(): void
{
$thesis = [
'identifier' => '2024-001',
'title' => 'My Thesis',
'authors' => 'John Doe',
'year' => '2024',
'synopsis' => 'A brief synopsis.',
'keywords' => 'art, design',
];
$html = $this->buildHtml($thesis);
$this->assertIsString($html);
$this->assertNotEmpty($html);
$this->assertStringContainsString('My Thesis', $html);
$this->assertStringContainsString('2024-001', $html);
$this->assertStringContainsString('John Doe', $html);
}
public function testBuildHtmlContainsKeyFields(): void
{
$thesis = [
'title' => 'Test Title',
'authors' => 'Alice',
'year' => '2025',
'identifier' => '2025-042',
'orientation' => 'BD',
];
$html = $this->buildHtml($thesis);
$this->assertStringContainsString('Test Title', $html);
$this->assertStringContainsString('Alice', $html);
$this->assertStringContainsString('2025', $html);
$this->assertStringContainsString('2025-042', $html);
$this->assertStringContainsString('BD', $html);
}
// ── HTML escaping ─────────────────────────────────────────────────────────
public function testBuildHtmlEscapesSpecialCharacters(): void
{
$thesis = [
'identifier' => '2024-999',
'title' => '<script>alert("xss")</script>',
'authors' => 'Evil & Co.',
'year' => '2024',
];
$html = $this->buildHtml($thesis);
$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('&lt;script&gt;', $html);
$this->assertStringContainsString('Evil &amp; Co.', $html);
}
// ── Missing / null optional fields ────────────────────────────────────────
public function testBuildHtmlHandlesMissingOptionalFields(): void
{
// Minimal thesis — only title and authors
$thesis = [
'title' => 'Solo',
'authors' => 'One Author',
];
$html = $this->buildHtml($thesis);
$this->assertIsString($html);
$this->assertNotEmpty($html);
// Should contain the "" dash placeholders for empty fields
$this->assertStringContainsString('Solo', $html);
$this->assertStringContainsString('One Author', $html);
}
public function testBuildHtmlHandlesNullFieldsGracefully(): void
{
$thesis = [
'identifier' => null,
'title' => 'Null Test',
'authors' => null,
'year' => null,
'subtitle' => null,
'orientation' => null,
];
$html = $this->buildHtml($thesis);
$this->assertIsString($html);
$this->assertNotEmpty($html);
$this->assertStringContainsString('Null Test', $html);
}
public function testBuildHtmlHandlesEmptyArray(): void
{
$html = $this->buildHtml([]);
$this->assertIsString($html);
$this->assertNotEmpty($html);
// Should not crash — just fill all fields with ""
}
public function testBuildHtmlContainsLabelFields(): void
{
$thesis = ['title' => 'X', 'authors' => 'Y'];
$html = $this->buildHtml($thesis);
$this->assertStringContainsString('Identifiant', $html);
$this->assertStringContainsString('Titre', $html);
$this->assertStringContainsString('Auteur·ice(s)', $html);
$this->assertStringContainsString('Année', $html);
$this->assertStringContainsString('Mots-clés', $html);
}
}

View File

@@ -0,0 +1,195 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* SystemControllerHelpersTest — Pure logic tests for the static helper methods
* in SystemController (humanBytes, diskColor, logLineClass, nginxLineClass,
* statusLabel, statusClass).
*
* These are stateless, no-IO functions.
*/
class SystemControllerHelpersTest extends TestCase
{
// ── humanBytes() ──────────────────────────────────────────────────────────
public function testHumanBytesZero(): void
{
$this->assertSame('0.0 KB', SystemController::humanBytes(0));
}
public function testHumanBytesBelowOneKB(): void
{
$this->assertSame('1.0 KB', SystemController::humanBytes(1023));
}
public function testHumanBytesOneKB(): void
{
$this->assertSame('1.0 KB', SystemController::humanBytes(1024));
}
public function testHumanBytesOneMB(): void
{
// threshold is > 1048576, so exactly 1048576 is still KB
$this->assertSame('1,024.0 KB', SystemController::humanBytes(1048576));
}
public function testHumanBytesOneGB(): void
{
// threshold is > 1073741824, so exactly that is still MB
$this->assertSame('1,024.0 MB', SystemController::humanBytes(1073741824));
}
public function testHumanBytes1523MB(): void
{
$bytes = (int)(1.5 * 1048576);
$this->assertSame('1.5 MB', SystemController::humanBytes($bytes));
}
public function testHumanBytes2500GB(): void
{
$bytes = (int)(2.5 * 1073741824);
$this->assertSame('2.5 GB', SystemController::humanBytes($bytes));
}
// ── diskColor() ───────────────────────────────────────────────────────────
public function testDiskColorBelowWarning(): void
{
$this->assertSame('#4caf50', SystemController::diskColor(0));
$this->assertSame('#4caf50', SystemController::diskColor(50));
$this->assertSame('#4caf50', SystemController::diskColor(70));
}
public function testDiskColorWarning(): void
{
$this->assertSame('#ffc107', SystemController::diskColor(71));
$this->assertSame('#ffc107', SystemController::diskColor(85));
}
public function testDiskColorCritical(): void
{
$this->assertSame('#e05555', SystemController::diskColor(86));
$this->assertSame('#e05555', SystemController::diskColor(99));
$this->assertSame('#e05555', SystemController::diskColor(100));
}
// ── logLineClass() ────────────────────────────────────────────────────────
public function testLogLineClassCrit(): void
{
$this->assertSame('log-crit', SystemController::logLineClass('[crit] Fatal'));
$this->assertSame('log-crit', SystemController::logLineClass('[emerg] Emergency'));
$this->assertSame('log-crit', SystemController::logLineClass('[alert] Alert'));
}
public function testLogLineClassError(): void
{
$this->assertSame('log-error', SystemController::logLineClass('[error] An error'));
}
public function testLogLineClassWarn(): void
{
$this->assertSame('log-warn', SystemController::logLineClass('[warn] Warning'));
}
public function testLogLineClassNotice(): void
{
$this->assertSame('log-notice', SystemController::logLineClass('[notice] Notice'));
}
public function testLogLineClassHttp500(): void
{
// Matches " 500 " pattern in nginx-style log
$this->assertSame('log-error', SystemController::logLineClass('GET / " 500 123 "'));
$this->assertSame('log-error', SystemController::logLineClass('GET / " 404 123 "'));
}
public function testLogLineClassHttp300(): void
{
$this->assertSame('log-notice', SystemController::logLineClass('GET / " 302 456 "'));
}
public function testLogLineClassDefault(): void
{
$this->assertSame('', SystemController::logLineClass('Just a plain log message'));
$this->assertSame('', SystemController::logLineClass('GET / " 200 123 "'));
}
// ── nginxLineClass() ──────────────────────────────────────────────────────
public function testNginxLineClassComment(): void
{
$this->assertSame('nginx-comment', SystemController::nginxLineClass('# comment'));
$this->assertSame('nginx-comment', SystemController::nginxLineClass(' # indented comment'));
$this->assertSame('nginx-comment', SystemController::nginxLineClass(''));
$this->assertSame('nginx-comment', SystemController::nginxLineClass(' '));
}
public function testNginxLineClassBlock(): void
{
$this->assertSame('nginx-block', SystemController::nginxLineClass('server {'));
$this->assertSame('nginx-block', SystemController::nginxLineClass('location / {'));
$this->assertSame('nginx-block', SystemController::nginxLineClass(' upstream backend {'));
$this->assertSame('nginx-block', SystemController::nginxLineClass('events {'));
$this->assertSame('nginx-block', SystemController::nginxLineClass('http {'));
}
public function testNginxLineClassDirective(): void
{
$this->assertSame('nginx-directive', SystemController::nginxLineClass('listen 80;'));
$this->assertSame('nginx-directive', SystemController::nginxLineClass('root /var/www;'));
$this->assertSame('nginx-directive', SystemController::nginxLineClass(' server_name example.com;'));
}
// ── statusLabel() ─────────────────────────────────────────────────────────
public function testStatusLabelActive(): void
{
$this->assertSame('● En ligne', SystemController::statusLabel('active'));
}
public function testStatusLabelInactive(): void
{
$this->assertSame('○ Inactif', SystemController::statusLabel('inactive'));
}
public function testStatusLabelFailed(): void
{
$this->assertSame('✕ Erreur', SystemController::statusLabel('failed'));
}
public function testStatusLabelWarn(): void
{
$this->assertSame('⚠ Attention', SystemController::statusLabel('warn'));
}
public function testStatusLabelUnknown(): void
{
$this->assertSame('? Inconnu', SystemController::statusLabel('unknown'));
$this->assertSame('? Inconnu', SystemController::statusLabel('bogus'));
}
// ── statusClass() ─────────────────────────────────────────────────────────
public function testStatusClassOk(): void
{
$this->assertSame('status-ok', SystemController::statusClass('active'));
}
public function testStatusClassWarn(): void
{
$this->assertSame('status-warn', SystemController::statusClass('inactive'));
$this->assertSame('status-warn', SystemController::statusClass('warn'));
}
public function testStatusClassError(): void
{
$this->assertSame('status-err', SystemController::statusClass('failed'));
}
public function testStatusClassUnknown(): void
{
$this->assertSame('status-unknown', SystemController::statusClass('bogus'));
}
}

View File

@@ -0,0 +1,199 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* TfeControllerOgTest — Pure logic tests for TfeController::buildOgTags()
* and buildMetaDescription().
*
* These methods are protected; we use a thin subclass to expose them.
*/
class TfeControllerOgTest extends TestCase
{
/**
* Subclass that exposes protected methods for testing.
*/
private static function makeController(): object
{
return new class extends TfeController {
public function __construct()
{
// Skip parent constructor — we don't need DB for these pure methods
}
public function exposedBuildOgTags(array $data, int $thesisId, string $metaDescription): array
{
return $this->buildOgTags($data, $thesisId, $metaDescription);
}
public function exposedBuildMetaDescription(string $synopsis): string
{
return $this->buildMetaDescription($synopsis);
}
public function exposedResolveOgImage(array $files): string
{
return $this->resolveOgImage($files);
}
};
}
// ── buildOgTags() ─────────────────────────────────────────────────────────
public function testBuildOgTagsReturnsAllRequiredKeys(): void
{
$ctrl = self::makeController();
$data = [
'title' => 'My Thesis',
'authors' => 'Jane Doe',
'year' => '2024',
'files' => [],
];
$tags = $ctrl->exposedBuildOgTags($data, 42, 'A description');
$requiredKeys = ['type', 'title', 'description', 'url', 'image', 'image_alt', 'site_name', 'article_author', 'article_published_time'];
foreach ($requiredKeys as $key) {
$this->assertArrayHasKey($key, $tags);
$this->assertIsString($tags[$key]);
}
}
public function testBuildOgTagsTitleIncludesAuthors(): void
{
$ctrl = self::makeController();
$data = [
'title' => 'The Title',
'authors' => 'Author Name',
'year' => '2024',
'files' => [],
];
$tags = $ctrl->exposedBuildOgTags($data, 1, 'desc');
$this->assertStringContainsString('The Title Author Name', $tags['title']);
// Without authors, just the title
$data['authors'] = '';
$tags2 = $ctrl->exposedBuildOgTags($data, 1, 'desc');
$this->assertSame('The Title', $tags2['title']);
}
public function testBuildOgTagsImageEmptyWhenNoFiles(): void
{
$ctrl = self::makeController();
$data = [
'title' => 'No Files',
'authors' => 'A',
'year' => '2023',
'files' => [],
];
$tags = $ctrl->exposedBuildOgTags($data, 5, 'desc');
$this->assertSame('', $tags['image']);
}
public function testBuildOgTagsImageFromCover(): void
{
$ctrl = self::makeController();
$data = [
'title' => 'Cover Test',
'authors' => 'Author',
'year' => '2024',
'files' => [
['file_path' => 'documents/2024-001/cover.jpg', 'file_type' => 'cover'],
],
];
$tags = $ctrl->exposedBuildOgTags($data, 1, 'desc');
$this->assertStringContainsString('cover.jpg', $tags['image']);
}
public function testBuildOgTagsImageFallbackToFirstImage(): void
{
$ctrl = self::makeController();
$data = [
'title' => 'Image Test',
'authors' => 'B',
'year' => '2024',
'files' => [
['file_path' => 'documents/2024-001/doc.pdf', 'file_type' => 'tfe'],
['file_path' => 'documents/2024-001/screenshot.png', 'file_type' => 'tfe'],
],
];
$tags = $ctrl->exposedBuildOgTags($data, 2, 'desc');
$this->assertStringContainsString('screenshot.png', $tags['image']);
}
public function testBuildOgTagsUrlIncludesThesisId(): void
{
$ctrl = self::makeController();
$data = ['title' => 'URL Test', 'authors' => '', 'year' => '2024', 'files' => []];
$tags = $ctrl->exposedBuildOgTags($data, 99, 'desc');
$this->assertStringContainsString('tfe?id=99', $tags['url']);
}
public function testBuildOgTagsPublishedTimeFormatted(): void
{
$ctrl = self::makeController();
$data = ['title' => 'T', 'authors' => '', 'year' => '2025', 'files' => []];
$tags = $ctrl->exposedBuildOgTags($data, 1, 'desc');
$this->assertSame('2025-01-01', $tags['article_published_time']);
}
public function testBuildOgTagsPublishedTimeEmptyWhenNoYear(): void
{
$ctrl = self::makeController();
$data = ['title' => 'T', 'authors' => '', 'files' => []];
$tags = $ctrl->exposedBuildOgTags($data, 1, 'desc');
$this->assertSame('', $tags['article_published_time']);
}
// ── buildMetaDescription() ────────────────────────────────────────────────
public function testBuildMetaDescriptionTruncatesLongSynopsis(): void
{
$ctrl = self::makeController();
$long = str_repeat('abcdefghij', 20); // 200 chars
$desc = $ctrl->exposedBuildMetaDescription($long);
$this->assertLessThanOrEqual(160, strlen($desc));
$this->assertStringEndsWith('…', $desc);
}
public function testBuildMetaDescriptionKeepsShortSynopsis(): void
{
$ctrl = self::makeController();
$desc = $ctrl->exposedBuildMetaDescription('A short synopsis.');
$this->assertSame('A short synopsis.', $desc);
}
public function testBuildMetaDescriptionEmptySynopsisReturnsDefault(): void
{
$ctrl = self::makeController();
$desc = $ctrl->exposedBuildMetaDescription('');
$this->assertStringContainsString('Mémoire', $desc);
$this->assertStringContainsString('XAMXAM', $desc);
}
public function testBuildMetaDescriptionStripsHtmlTags(): void
{
$ctrl = self::makeController();
$desc = $ctrl->exposedBuildMetaDescription('<p>Hello <b>world</b></p>');
$this->assertStringNotContainsString('<p>', $desc);
$this->assertStringNotContainsString('<b>', $desc);
$this->assertStringContainsString('Hello world', $desc);
}
}