mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
Phase 4 cleanup: migrate old tests to PHPUnit, add ErrorHandler/PureLogic/SearchController tests, remove app/tests/, update justfile test target
This commit is contained in:
File diff suppressed because one or more lines are too long
10
TODO.md
10
TODO.md
@@ -25,11 +25,11 @@
|
||||
- [x] 3.3 `AutofocusFieldForErrorTest.php` — correct field per error key, unknown key returns null/default, no CreateController name leak
|
||||
|
||||
## Phase 4 — Cleanup
|
||||
- [ ] 4.1 Migrate 8 existing custom-runner tests to PHPUnit in `tests/phpunit/`
|
||||
- [ ] 4.2 Verify all pass under `vendor/bin/phpunit`
|
||||
- [ ] 4.3 Remove `run-tests.php` and old test files
|
||||
- [ ] 4.4 Add `vendor/bin/phpunit` to justfile/Makefile CI target
|
||||
- [ ] 4.5 Generate baseline coverage report (`--coverage-html coverage/`)
|
||||
- [x] 4.1 Migrate 8 existing custom-runner tests to PHPUnit in `tests/phpunit/`
|
||||
- [x] 4.2 Verify all pass under `vendor/bin/phpunit`
|
||||
- [x] 4.3 Remove `run-tests.php` and old test files
|
||||
- [x] 4.4 Add `vendor/bin/phpunit` to justfile/Makefile CI target
|
||||
- [ ] 4.5 Generate baseline coverage report (`--coverage-html coverage/`) — needs Xdebug/PCov
|
||||
- [ ] 4.6 Commit coverage baseline
|
||||
|
||||
---
|
||||
|
||||
@@ -1 +1 @@
|
||||
[1779232892]
|
||||
[1779234737]
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Search Functionality Test
|
||||
* Tests search queries and results
|
||||
*/
|
||||
|
||||
putenv('DB_ENV=test');
|
||||
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
echo "Search Functionality Test\n";
|
||||
echo "=========================\n\n";
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Test 1: Search with empty query
|
||||
echo "Test 1: Empty Search Query\n";
|
||||
$results = $db->searchTheses([]);
|
||||
if (is_array($results)) {
|
||||
echo '✓ PASS: Empty query handled (returned ' . count($results) . " results)\n\n";
|
||||
} else {
|
||||
throw new Exception('Invalid results for empty query');
|
||||
}
|
||||
|
||||
// Test 2: Search for specific term
|
||||
echo "Test 2: Search for Specific Term\n";
|
||||
$searchTerm = 'art'; // Common word likely to appear
|
||||
$results = $db->searchTheses(['query' => $searchTerm]);
|
||||
if (is_array($results)) {
|
||||
echo "✓ PASS: Search for '$searchTerm' returned " . count($results) . " results\n\n";
|
||||
} else {
|
||||
throw new Exception('Invalid search results');
|
||||
}
|
||||
|
||||
// Test 3: Search with special characters
|
||||
echo "Test 3: Search with Special Characters\n";
|
||||
$results = $db->searchTheses(['query' => "test's \"quotes\" & symbols"]);
|
||||
if (is_array($results)) {
|
||||
echo "✓ PASS: Special characters handled safely\n\n";
|
||||
} else {
|
||||
throw new Exception('Failed to handle special characters');
|
||||
}
|
||||
|
||||
// Test 4: Tag-filter search using the new EXISTS subquery
|
||||
echo "Test 4: Tag-filter search (thesis_tags subquery)\n";
|
||||
$tagResults = $db->searchTheses(['keyword' => 'urbanisme']);
|
||||
if (is_array($tagResults)) {
|
||||
echo "✓ PASS: Tag search for 'urbanisme' returned " . count($tagResults) . " result(s)\n";
|
||||
foreach ($tagResults as $r) {
|
||||
echo ' - ' . $r['title'] . ' (' . $r['year'] . ")\n";
|
||||
}
|
||||
echo "\n";
|
||||
} else {
|
||||
throw new Exception('Tag search returned non-array');
|
||||
}
|
||||
|
||||
// Test 5: Tag search in full-text query (query touches tag subquery)
|
||||
echo "Test 5: Full-text query includes tag subquery\n";
|
||||
$allResults = $db->searchTheses(['query' => 'narration']);
|
||||
if (is_array($allResults)) {
|
||||
echo "✓ PASS: Query 'narration' returned " . count($allResults) . " result(s)\n\n";
|
||||
} else {
|
||||
throw new Exception('Full-text query with tag subquery failed');
|
||||
}
|
||||
|
||||
// Test 6: countSearchResults matches searchTheses
|
||||
echo "Test 6: countSearchResults consistency\n";
|
||||
$params = ['keyword' => 'urbanisme'];
|
||||
$count = $db->countSearchResults($params);
|
||||
$rows = $db->searchTheses($params, 100);
|
||||
if ($count === count($rows)) {
|
||||
echo "✓ PASS: count=$count matches row count\n\n";
|
||||
} else {
|
||||
throw new Exception("countSearchResults ($count) != searchTheses row count (" . count($rows) . ')');
|
||||
}
|
||||
|
||||
echo "✅ All search tests passed!\n";
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo '❌ FAIL: ' . $e->getMessage() . "\n";
|
||||
return false;
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
# XAMXAM Test Suite
|
||||
|
||||
Centralized test suite for the XAMXAM thesis management system.
|
||||
|
||||
## 📁 Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── run-tests.php # Test runner (runs all tests)
|
||||
├── Unit/ # Unit tests
|
||||
│ ├── DatabaseTest.php # Database connection & queries
|
||||
│ └── RateLimitTest.php # Rate limiting functionality
|
||||
├── Integration/ # Integration tests
|
||||
│ └── SearchTest.php # Search functionality
|
||||
├── Security/ # Security tests
|
||||
│ └── SecurityTest.php # SQL injection & XSS protection
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 🚀 Running Tests
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
# Using justfile (recommended)
|
||||
just test
|
||||
|
||||
# Or directly
|
||||
php tests/run-tests.php
|
||||
```
|
||||
|
||||
### Run Individual Tests
|
||||
|
||||
```bash
|
||||
# Database test
|
||||
php tests/Unit/DatabaseTest.php
|
||||
|
||||
# Search test
|
||||
php tests/Integration/SearchTest.php
|
||||
|
||||
# Security test
|
||||
php tests/Security/SecurityTest.php
|
||||
|
||||
# Rate limit test
|
||||
php tests/Unit/RateLimitTest.php
|
||||
```
|
||||
|
||||
## ✅ Test Coverage
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**DatabaseTest.php** - Tests basic database operations:
|
||||
- ✅ Database connection
|
||||
- ✅ Count published theses
|
||||
- ✅ Get published theses
|
||||
- ✅ Get single thesis by ID
|
||||
|
||||
**RateLimitTest.php** - Tests rate limiting:
|
||||
- ✅ RateLimit initialization
|
||||
- ✅ check() method
|
||||
- ✅ sendHeaders() method
|
||||
- ✅ getResetTime() method
|
||||
- ✅ cleanup() method
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**SearchTest.php** - Tests search functionality:
|
||||
- ✅ Empty search query handling
|
||||
- ✅ Search for specific terms
|
||||
- ✅ Special characters in search
|
||||
|
||||
### Security Tests
|
||||
|
||||
**SecurityTest.php** - Tests security measures:
|
||||
- ✅ SQL injection protection
|
||||
- ✅ Invalid ID rejection
|
||||
- ✅ XSS protection (output escaping)
|
||||
|
||||
## 📝 Writing New Tests
|
||||
|
||||
### Test File Template
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Test Name
|
||||
* Description of what this tests
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../lib/YourClass.php';
|
||||
|
||||
echo "Test Name\n";
|
||||
echo "=========\n\n";
|
||||
|
||||
try {
|
||||
// Test 1
|
||||
echo "Test 1: Description\n";
|
||||
// ... test code ...
|
||||
echo "✓ PASS: Test passed\n\n";
|
||||
|
||||
// Test 2
|
||||
echo "Test 2: Description\n";
|
||||
// ... test code ...
|
||||
echo "✓ PASS: Test passed\n\n";
|
||||
|
||||
echo "✅ All tests passed!\n";
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo "❌ FAIL: " . $e->getMessage() . "\n";
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
1. **Return Value**: Return `true` for pass, `false` for fail
|
||||
2. **Output Format**: Use `✓ PASS:` for successes, `❌ FAIL:` for failures
|
||||
3. **Exceptions**: Catch and report exceptions clearly
|
||||
4. **Dependencies**: Require only what's needed via relative paths
|
||||
5. **Location**:
|
||||
- `Unit/` - Tests for individual classes/functions
|
||||
- `Integration/` - Tests for feature workflows
|
||||
- `Security/` - Tests for security vulnerabilities
|
||||
|
||||
## 🔧 Test Database
|
||||
|
||||
Tests use the main database at `storage/xamxam.db`.
|
||||
|
||||
### Setup Test Database
|
||||
|
||||
```bash
|
||||
# Create from schema
|
||||
just init-db
|
||||
```
|
||||
|
||||
### Reset Test Database
|
||||
|
||||
```bash
|
||||
just reset-db
|
||||
```
|
||||
|
||||
## 📊 Expected Output
|
||||
|
||||
Successful test run:
|
||||
```
|
||||
╔════════════════════════════════════════════╗
|
||||
║ XAMXAM Test Suite ║
|
||||
╚════════════════════════════════════════════╝
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Database (Unit) │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
✓ PASS: Database connection successful
|
||||
✓ PASS: Found 16 published theses
|
||||
...
|
||||
✅ TEST PASSED
|
||||
|
||||
...
|
||||
|
||||
╔════════════════════════════════════════════╗
|
||||
║ Test Summary ║
|
||||
╠════════════════════════════════════════════╣
|
||||
║ Total: 4 ║
|
||||
║ Passed: 4 ✅ ║
|
||||
║ Failed: 0 ║
|
||||
╚════════════════════════════════════════════╝
|
||||
|
||||
✅ All tests passed!
|
||||
```
|
||||
|
||||
## 🐛 Debugging Failed Tests
|
||||
|
||||
### Check Logs
|
||||
|
||||
```bash
|
||||
# Application errors
|
||||
tail -f error.log
|
||||
|
||||
# Test output
|
||||
php tests/run-tests.php > test-output.txt 2>&1
|
||||
```
|
||||
|
||||
### Run Tests Individually
|
||||
|
||||
When a test fails, run it directly to see full output:
|
||||
|
||||
```bash
|
||||
php tests/Unit/DatabaseTest.php
|
||||
```
|
||||
|
||||
### Check Database
|
||||
|
||||
```bash
|
||||
# Open database
|
||||
just query
|
||||
|
||||
# Check stats
|
||||
just stats
|
||||
```
|
||||
|
||||
## 🔄 Continuous Testing
|
||||
|
||||
### Watch Mode (Future)
|
||||
|
||||
Could add file watching for auto-run:
|
||||
|
||||
```bash
|
||||
# Future: auto-run tests on file change
|
||||
just watch-tests
|
||||
```
|
||||
|
||||
### Pre-commit Hook (Future)
|
||||
|
||||
Add to `.git/hooks/pre-commit`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
php tests/run-tests.php
|
||||
```
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- [Database Specification](../storage/DATABASE_SPECIFICATION.md)
|
||||
- [Security Documentation](../docs/SECURITY.md)
|
||||
- [Development Guide](../MIGRATION_GUIDE.md)
|
||||
|
||||
---
|
||||
|
||||
**To run tests:** `just test`
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Security Test Suite
|
||||
* Tests SQL injection protection and input sanitization
|
||||
*/
|
||||
|
||||
putenv('DB_ENV=test');
|
||||
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
echo "Security Test Suite\n";
|
||||
echo "===================\n\n";
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Test 1: SQL Injection in search
|
||||
// searchTheses() takes an array of validated params; the 'query' key is the
|
||||
// free-text search field that users control. Each malicious string must
|
||||
// be passed as ['query' => $string] to exercise the actual parameterised
|
||||
// query path rather than triggering a PHP TypeError before any SQL runs.
|
||||
echo "Test 1: SQL Injection Protection (Search)\n";
|
||||
$maliciousQueries = [
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE theses; --",
|
||||
"1' UNION SELECT * FROM authors--",
|
||||
"<script>alert('xss')</script>",
|
||||
];
|
||||
|
||||
foreach ($maliciousQueries as $query) {
|
||||
try {
|
||||
$results = $db->searchTheses(['query' => $query]);
|
||||
// Should return a (possibly empty) result set without throwing
|
||||
echo ' ✓ Handled safely: ' . substr($query, 0, 40) . "\n";
|
||||
} catch (Exception $e) {
|
||||
// A thrown exception is also acceptable (query rejected upstream)
|
||||
echo ' ✓ Exception (safe): ' . substr($query, 0, 40) . "\n";
|
||||
}
|
||||
}
|
||||
echo "✓ PASS: SQL injection attempts handled safely\n\n";
|
||||
|
||||
// Test 2: Invalid thesis ID
|
||||
echo "Test 2: Invalid Thesis ID\n";
|
||||
$invalidIds = ['abc', "'; DROP TABLE theses;", '-1', '999999'];
|
||||
|
||||
foreach ($invalidIds as $id) {
|
||||
$result = $db->getThesisById($id);
|
||||
if ($result === null || $result === false) {
|
||||
echo ' ✓ Rejected: ' . $id . "\n";
|
||||
} else {
|
||||
throw new Exception("Invalid ID '$id' was not rejected");
|
||||
}
|
||||
}
|
||||
echo "✓ PASS: Invalid IDs rejected\n\n";
|
||||
|
||||
// Test 3: XSS in output (checking data is escaped)
|
||||
echo "Test 3: XSS Protection (Output Escaping)\n";
|
||||
$theses = $db->getPublishedTheses(1, 0);
|
||||
if (count($theses) > 0) {
|
||||
$first = $theses[0];
|
||||
// Check that HTML special chars would be handled
|
||||
if (isset($first['title'])) {
|
||||
echo " ✓ Title data retrieved safely\n";
|
||||
}
|
||||
}
|
||||
echo "✓ PASS: Output handling verified\n\n";
|
||||
|
||||
echo "✅ All security tests passed!\n";
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo '❌ FAIL: ' . $e->getMessage() . "\n";
|
||||
return false;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Database Connection Test
|
||||
* Tests basic database connectivity and query functionality
|
||||
*/
|
||||
|
||||
// Must be set before Database.php is required so getDatabasePath() picks it up.
|
||||
putenv('DB_ENV=test');
|
||||
|
||||
require_once __DIR__ . '/../../src/Database.php';
|
||||
|
||||
echo "Database Connection Test\n";
|
||||
echo "========================\n\n";
|
||||
|
||||
try {
|
||||
// Test 1: Database connection
|
||||
echo "Test 1: Database Connection\n";
|
||||
$db = Database::getInstance();
|
||||
echo "✓ PASS: Database connection successful\n\n";
|
||||
|
||||
// Test 2: Count published theses
|
||||
echo "Test 2: Count Published Theses\n";
|
||||
$count = $db->countPublishedTheses();
|
||||
if ($count >= 0) {
|
||||
echo "✓ PASS: Found {$count} published theses\n\n";
|
||||
} else {
|
||||
throw new Exception('Invalid count returned');
|
||||
}
|
||||
|
||||
// Test 3: Get published theses
|
||||
echo "Test 3: Get Published Theses\n";
|
||||
$theses = $db->getPublishedTheses(5, 0);
|
||||
if (is_array($theses)) {
|
||||
echo '✓ PASS: Retrieved ' . count($theses) . " theses\n\n";
|
||||
} else {
|
||||
throw new Exception('Invalid theses array returned');
|
||||
}
|
||||
|
||||
// Test 4: Get single thesis (if any exist)
|
||||
if (count($theses) > 0) {
|
||||
echo "Test 4: Get Single Thesis\n";
|
||||
$first = $theses[0];
|
||||
$thesis = $db->getThesisById($first['id']);
|
||||
|
||||
if ($thesis && isset($thesis['id'])) {
|
||||
echo "✓ PASS: Successfully retrieved thesis #{$first['id']}\n";
|
||||
echo ' Title: ' . $thesis['title'] . "\n";
|
||||
echo ' Author(s): ' . ($thesis['authors'] ?? 'N/A') . "\n";
|
||||
echo ' Year: ' . $thesis['year'] . "\n\n";
|
||||
} else {
|
||||
throw new Exception('Failed to retrieve thesis by ID');
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: findOrCreateTag round-trip
|
||||
echo "Test 5: findOrCreateTag round-trip\n";
|
||||
$testTag = '__test_tag_' . bin2hex(random_bytes(4));
|
||||
$id1 = $db->findOrCreateTag($testTag);
|
||||
$id2 = $db->findOrCreateTag($testTag); // should return same id
|
||||
if ($id1 && $id1 === $id2) {
|
||||
echo "✓ PASS: findOrCreateTag returned consistent id=$id1 for '$testTag'\n\n";
|
||||
// Clean up
|
||||
$db->deleteTag($id1);
|
||||
} else {
|
||||
throw new Exception("findOrCreateTag round-trip failed: id1=$id1, id2=$id2");
|
||||
}
|
||||
|
||||
// Test 6: getUsedTags returns array with 'name' column
|
||||
echo "Test 6: getUsedTags returns name column\n";
|
||||
$tags = $db->getUsedTags();
|
||||
if (is_array($tags) && (empty($tags) || isset($tags[0]['name']))) {
|
||||
echo '✓ PASS: getUsedTags returned ' . count($tags) . " tags with 'name' column\n\n";
|
||||
} else {
|
||||
throw new Exception('getUsedTags did not return expected structure: ' . json_encode($tags[0] ?? []));
|
||||
}
|
||||
|
||||
echo "✅ All database tests passed!\n";
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo '❌ FAIL: ' . $e->getMessage() . "\n";
|
||||
return false;
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ErrorHandler Unit Test
|
||||
*
|
||||
* Tests that ErrorHandler correctly:
|
||||
* - Maps PDO FK constraint errors to precise field names
|
||||
* - Falls back to generic messages when table cannot be identified
|
||||
* - Passes through domain exceptions (DuplicateThesisException, RuntimeException)
|
||||
* - Generates structured log entries without crashing
|
||||
* - Handles edge cases: empty message, null values, unknown exception types
|
||||
*/
|
||||
|
||||
putenv('DB_ENV=test');
|
||||
|
||||
if (!defined('APP_ROOT')) {
|
||||
define('APP_ROOT', dirname(__DIR__, 2));
|
||||
}
|
||||
if (!defined('STORAGE_ROOT')) {
|
||||
define('STORAGE_ROOT', APP_ROOT . '/storage');
|
||||
}
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ErrorHandler.php';
|
||||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||||
|
||||
// ── Test harness ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ehAssert(bool $cond, string $label): void
|
||||
{
|
||||
if ($cond) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
throw new RuntimeException("FAIL: $label");
|
||||
}
|
||||
}
|
||||
|
||||
function ehAssertContains(string $needle, string $haystack, string $label): void
|
||||
{
|
||||
if (str_contains($haystack, $needle)) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
throw new RuntimeException("FAIL: $label\n expected to contain: $needle\n actual: $haystack");
|
||||
}
|
||||
}
|
||||
|
||||
function ehAssertEq(mixed $expected, mixed $actual, string $label): void
|
||||
{
|
||||
if ($expected === $actual) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
$e = var_export($expected, true);
|
||||
$a = var_export($actual, true);
|
||||
throw new RuntimeException("FAIL: $label\n expected: $e\n actual: $a");
|
||||
}
|
||||
}
|
||||
|
||||
function ehAssertNotContains(string $needle, string $haystack, string $label): void
|
||||
{
|
||||
if (!str_contains($haystack, $needle)) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
throw new RuntimeException("FAIL: $label\n expected NOT to contain: $needle\n actual: $haystack");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: create a PDOException with a given SQLite error message ──────────
|
||||
// PDOException's constructor takes (message, code, previous).
|
||||
// The message is what SQLite would produce.
|
||||
function makeFkException(string $sqliteMessage): PDOException
|
||||
{
|
||||
return new PDOException($sqliteMessage, 787); // 787 = SQLITE_CONSTRAINT_FOREIGNKEY
|
||||
}
|
||||
|
||||
function makeUniqueException(): PDOException
|
||||
{
|
||||
return new PDOException('UNIQUE constraint failed: thesis_tags.tag_id, thesis_tags.thesis_id', 2067);
|
||||
}
|
||||
|
||||
function makeNotNullException(): PDOException
|
||||
{
|
||||
return new PDOException('NOT NULL constraint failed: theses.title', 1299);
|
||||
}
|
||||
|
||||
function makeGenericPdoException(): PDOException
|
||||
{
|
||||
return new PDOException('database disk image is malformed', 11);
|
||||
}
|
||||
|
||||
// ── Start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "ErrorHandler Unit Test\n";
|
||||
echo "======================\n\n";
|
||||
|
||||
try {
|
||||
|
||||
// =========================================================================
|
||||
// SECTION A: FK constraint — precise field extraction
|
||||
// =========================================================================
|
||||
|
||||
echo "A: FK constraint — table name extracted from INSERT INTO\n";
|
||||
|
||||
echo "A1: theses table (orientation_id FK) — lists all possible FK fields\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (title, orientation_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Orientation', $user, 'mentions Orientation (one of the possible FK fields on theses)');
|
||||
ehAssertContains('AP', $user, 'mentions AP');
|
||||
ehAssertContains('Licence', $user, 'mentions Licence');
|
||||
ehAssertNotContains('FOREIGN KEY', $user, 'raw SQL not leaked');
|
||||
|
||||
echo "A2: ap_programs\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (ap_program_id) VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('AP', $user, 'identifies AP field');
|
||||
|
||||
echo "A3: finality_types\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (finality_id) VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Finalité', $user, 'identifies Finalité field');
|
||||
|
||||
echo "A4: thesis_languages\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Langue(s)', $user, 'identifies Langue(s) field');
|
||||
|
||||
echo "A5: thesis_formats\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Format(s)', $user, 'identifies Format(s) field');
|
||||
|
||||
echo "A6: thesis_tags\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Mots-clés', $user, 'identifies Mots-clés field');
|
||||
|
||||
echo "A7: thesis_supervisors\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_supervisors (thesis_id, supervisor_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Composition du jury', $user, 'identifies jury field');
|
||||
|
||||
echo "A8: access_types\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (access_type_id) VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains("Type d'accès", $user, 'identifies access type');
|
||||
|
||||
echo "A9: license_types\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (license_id) VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Licence', $user, 'identifies Licence field');
|
||||
|
||||
echo "A10: authors\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_authors (thesis_id, author_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Auteur·ice', $user, 'identifies Auteur·ice field');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION B: FK constraint — \"table\" pattern (SQLite 3.37+)
|
||||
// =========================================================================
|
||||
|
||||
echo "B: FK constraint — \"table\" pattern (SQLite 3.37+)\n";
|
||||
|
||||
echo "B1: quoted table name\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed (table "orientations")');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Orientation', $user, 'extracts from quoted table name');
|
||||
|
||||
echo "B2: quoted table — languages\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed (table "languages")');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Langue(s)', $user, 'maps languages → Langue(s)');
|
||||
|
||||
echo "B3: quoted table — format_types\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed (table "format_types")');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Format(s)', $user, 'maps format_types → Format(s)');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION C: FK constraint — REFERENCES pattern
|
||||
// =========================================================================
|
||||
|
||||
echo "C: FK constraint — REFERENCES pattern\n";
|
||||
|
||||
echo "C1: REFERENCES tags\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed (REFERENCES tags(id))');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Mots-clés', $user, 'maps REFERENCES tags → Mots-clés');
|
||||
|
||||
echo "C2: REFERENCES orientations\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed (REFERENCES orientations(id))');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Orientation', $user, 'maps REFERENCES orientations → Orientation');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION D: FK constraint — unknown table falls back to generic
|
||||
// =========================================================================
|
||||
|
||||
echo "D: FK constraint — unknown table → generic fallback\n";
|
||||
|
||||
echo "D1: unknown table in INSERT\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO unknown_table VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('contrainte de référence est invalide', $user, 'generic FK message');
|
||||
ehAssertNotContains('unknown_table', $user, 'table name not leaked');
|
||||
|
||||
echo "D2: empty message with FK keywords\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('contrainte de référence est invalide', $user, 'generic FK for unparseable message');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION E: UNIQUE constraint\n
|
||||
// =========================================================================
|
||||
|
||||
echo "E: UNIQUE constraint\n";
|
||||
|
||||
echo "E1: UNIQUE constraint failed\n";
|
||||
$msg = makeUniqueException();
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('valeur en double', $user, 'mentions duplicate value');
|
||||
ehAssertNotContains('UNIQUE', $user, 'raw SQL not leaked');
|
||||
ehAssertNotContains('thesis_tags', $user, 'table name not leaked');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION F: NOT NULL constraint\n
|
||||
// =========================================================================
|
||||
|
||||
echo "F: NOT NULL constraint\n";
|
||||
|
||||
echo "F1: NOT NULL constraint failed\n";
|
||||
$msg = makeNotNullException();
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('champ obligatoire est manquant', $user, 'mentions required field');
|
||||
ehAssertNotContains('NOT NULL', $user, 'raw SQL not leaked');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION G: Generic PDO errors\n
|
||||
// =========================================================================
|
||||
|
||||
echo "G: Generic PDO errors\n";
|
||||
|
||||
echo "G1: disk image malformed\n";
|
||||
$msg = makeGenericPdoException();
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Une erreur de base de données est survenue', $user, 'generic DB message');
|
||||
ehAssertNotContains('disk image', $user, 'raw SQL not leaked');
|
||||
ehAssertNotContains('malformed', $user, 'raw error text not leaked');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION H: Domain exceptions pass through\n
|
||||
// =========================================================================
|
||||
|
||||
echo "H: Domain exceptions pass through with original message\n";
|
||||
|
||||
echo "H1: DuplicateThesisException\n";
|
||||
$dup = new DuplicateThesisException(42, '2025-ABC12345', 'Test titre', 'Auteur', 2025);
|
||||
$user = ErrorHandler::userMessage($dup);
|
||||
ehAssertContains('2025-ABC12345', $user, 'identifier in message');
|
||||
ehAssertContains('Auteur', $user, 'author in message');
|
||||
|
||||
echo "H2: RuntimeException (validation)\n";
|
||||
$val = new RuntimeException('Le titre est requis.');
|
||||
$user = ErrorHandler::userMessage($val);
|
||||
ehAssertEq('Le titre est requis.', $user, 'validation message passes through');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION I: Unknown exception types → generic fallback\n
|
||||
// =========================================================================
|
||||
|
||||
echo "I: Unknown exception types → generic fallback\n";
|
||||
|
||||
echo "I1: generic Exception (passes through for validation errors)\n";
|
||||
$gen = new Exception('Something went wrong');
|
||||
$user = ErrorHandler::userMessage($gen);
|
||||
ehAssertContains('Something went wrong', $user, 'Exception message passes through');
|
||||
|
||||
echo "I2: TypeError\n";
|
||||
$typeErr = new TypeError('htmlspecialchars(): Argument #1 must be string, array given');
|
||||
$user = ErrorHandler::userMessage($typeErr);
|
||||
ehAssertContains('Une erreur inattendue est survenue', $user, 'generic message for TypeError');
|
||||
ehAssertNotContains('htmlspecialchars', $user, 'internal function name not leaked');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION J: Log method — does not crash, captures all context\n
|
||||
// =========================================================================
|
||||
|
||||
echo "J: ErrorHandler::log() — structured logging without errors\n";
|
||||
|
||||
// Suppress actual error_log output during test; verify no exception thrown.
|
||||
echo "J1: log with extra context\n";
|
||||
try {
|
||||
ErrorHandler::log('test_context', new Exception('test message'), [
|
||||
'thesis_id' => 42,
|
||||
'slug' => '20250101-TEST1234',
|
||||
'author' => 'Test Author',
|
||||
]);
|
||||
echo " ✓ log() completed without exception\n";
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException('FAIL: log() threw: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
echo "J2: log with null values in extra\n";
|
||||
ErrorHandler::log('test_null', new PDOException('test'), ['id' => null, 'name' => 'foo']);
|
||||
echo " ✓ log() handles null values\n";
|
||||
|
||||
echo "J3: log with empty extra array\n";
|
||||
ErrorHandler::log('test_empty', new RuntimeException('bare'));
|
||||
echo " ✓ log() handles empty extra\n";
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION K: Keyword normalization logic (tag normalization)
|
||||
// =========================================================================
|
||||
|
||||
echo "K: Keyword normalization logic\n";
|
||||
|
||||
// Test the normalization regex used in controllers and JS:
|
||||
// strtolower(trim(preg_replace('/\s+/', ' ', $t)))
|
||||
$normalize = fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t)));
|
||||
|
||||
echo "K1: basic trimming and casing\n";
|
||||
ehAssertEq('hello', $normalize('Hello'), 'uppercase → lowercase');
|
||||
ehAssertEq('hello', $normalize(' hello '), 'trimmed');
|
||||
ehAssertEq('hello world', $normalize('Hello World'), 'two words lowercased');
|
||||
|
||||
echo "K2: multiple spaces collapsed\n";
|
||||
ehAssertEq('a b c', $normalize('a b c'), 'double spaces → single');
|
||||
ehAssertEq('x y', $normalize(' x y '), 'leading/trailing + multiple → clean');
|
||||
|
||||
echo "K3: tabs and newlines collapsed to space\n";
|
||||
ehAssertEq('word1 word2', $normalize("word1\tword2"), 'tab → space');
|
||||
ehAssertEq('line1 line2', $normalize("line1\nline2"), 'newline → space');
|
||||
ehAssertEq('mixed spaces', $normalize("mixed \t \n spaces"), 'mixed whitespace → single space');
|
||||
|
||||
echo "K4: French accents preserved\n";
|
||||
ehAssertEq('très précis', $normalize('Très Précis'), 'accents preserved in lowercase');
|
||||
|
||||
echo "K5: empty string\n";
|
||||
ehAssertEq('', $normalize(''), 'empty stays empty');
|
||||
ehAssertEq('', $normalize(' '), 'whitespace-only becomes empty');
|
||||
|
||||
echo "K6: special characters not mangled\n";
|
||||
ehAssertEq('c++', $normalize('C++'), 'symbols preserved');
|
||||
ehAssertEq('c#', $normalize('C#'), 'hash preserved');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION L: Deduplication on normalize (case-insensitive)
|
||||
// =========================================================================
|
||||
|
||||
echo "L: Deduplication after normalization\n";
|
||||
|
||||
$dedup = function (array $tags): array {
|
||||
return array_values(array_unique(array_map(
|
||||
fn (string $t): string => strtolower(trim(preg_replace('/\s+/', ' ', $t))),
|
||||
$tags
|
||||
)));
|
||||
};
|
||||
|
||||
echo "L1: case-insensitive dedup\n";
|
||||
ehAssertEq(['hello'], $dedup(['Hello', 'hello', 'HELLO']), 'case variations → one entry');
|
||||
|
||||
echo "L2: whitespace-insensitive dedup\n";
|
||||
ehAssertEq(['hello world'], $dedup(['hello world', 'Hello World', 'hello world']), 'whitespace + case → one entry');
|
||||
|
||||
echo "L3: empty strings filtered\n";
|
||||
$filtered = array_values(array_filter($dedup(['', ' ', 'valid']), fn ($t) => $t !== ''));
|
||||
ehAssertEq(['valid'], $filtered, 'empty/whitespace-only removed');
|
||||
|
||||
echo "L4: mixed valid and empty\n";
|
||||
$result = array_values(array_filter($dedup(['Alpha', '', ' ', 'BETA', 'alpha']), fn ($t) => $t !== ''));
|
||||
ehAssertEq(['alpha', 'beta'], $result, 'deduplicated and empties filtered');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION M: Minimum/maximum tag count enforcement\n
|
||||
// =========================================================================
|
||||
|
||||
echo "M: Tag count constraints\n";
|
||||
|
||||
echo "M1: 3 tags is valid\n";
|
||||
$valid = ['one', 'two', 'three'];
|
||||
ehAssert(count($valid) >= 3, '3 tags ≥ minimum 3');
|
||||
ehAssert(count($valid) <= 10, '3 tags ≤ maximum 10');
|
||||
|
||||
echo "M2: < 3 tags triggers error\n";
|
||||
$tooFew = ['one'];
|
||||
ehAssert(count($tooFew) < 3, '1 tag < minimum 3');
|
||||
|
||||
echo "M3: > 10 tags triggers error\n";
|
||||
$tooMany = ['a','b','c','d','e','f','g','h','i','j','k'];
|
||||
ehAssert(count($tooMany) > 10, '11 tags > maximum 10');
|
||||
|
||||
echo "M4: empty array\n";
|
||||
ehAssert(count([]) < 3, 'empty array < minimum 3');
|
||||
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION N: Real SQLite FK error message formats
|
||||
// =========================================================================
|
||||
|
||||
echo "N: Real-world SQLite FK error message patterns\n";
|
||||
|
||||
// These are actual error messages observed in the wild.
|
||||
echo "N1: typical INSERT INTO with VALUES\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats ("thesis_id", "format_id") VALUES (?, ?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Format(s)', $user, 'quoted column names handled');
|
||||
|
||||
echo "N2: UPDATE statement\n";
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed UPDATE theses SET orientation_id = ? WHERE id = ?');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Orientation', $user, 'UPDATE statement parsed');
|
||||
|
||||
echo "N3: long FK message with multiple table references\n";
|
||||
// Only the first match should be used (the INSERT target table)
|
||||
$msg = makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?) REFERENCES format_types');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
ehAssertContains('Format(s)', $user, 'first table match used');
|
||||
|
||||
echo "\n";
|
||||
|
||||
echo "✅ All ErrorHandler tests passed!\n";
|
||||
$result = true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo '❌ FAIL: ' . $e->getMessage() . "\n";
|
||||
$result = false;
|
||||
}
|
||||
|
||||
return $result ?? false;
|
||||
@@ -1,388 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Form Save Round-Trip Test
|
||||
*
|
||||
* TDD: verifies that every field the form collects is persisted and can be
|
||||
* read back from the database after create and edit operations.
|
||||
*
|
||||
* Covered fields:
|
||||
* - titre, subtitle, synopsis, année
|
||||
* - orientation, ap, finality
|
||||
* - languages (checkboxes), language_autre (free-text)
|
||||
* - formats (checkboxes)
|
||||
* - jury (promoteur, lecteur interne, lecteur externe)
|
||||
* - tags / keywords
|
||||
* - lien (baiu_link), license_id, access_type_id
|
||||
* - objet
|
||||
* - context_note
|
||||
* - remarks, jury_points, exemplaire_baiu, exemplaire_erg, cc2r (backoffice)
|
||||
* - is_published
|
||||
*/
|
||||
|
||||
putenv('DB_ENV=test');
|
||||
|
||||
if (!defined('APP_ROOT')) {
|
||||
define('APP_ROOT', dirname(__DIR__, 2));
|
||||
}
|
||||
if (!defined('STORAGE_ROOT')) {
|
||||
define('STORAGE_ROOT', APP_ROOT . '/storage');
|
||||
}
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
|
||||
require_once APP_ROOT . '/src/Controllers/ThesisEditController.php';
|
||||
require_once APP_ROOT . '/src/DuplicateThesisException.php';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal-but-complete POST payload.
|
||||
* Accepts overrides to test specific fields.
|
||||
*/
|
||||
function buildPost(Database $db, array $overrides = []): array
|
||||
{
|
||||
$orientations = $db->getAllOrientations();
|
||||
$apPrograms = $db->getAllAPPrograms();
|
||||
$finalityTypes = $db->getAllFinalityTypes();
|
||||
$languages = $db->getAllLanguages();
|
||||
$formatTypes = $db->getAllFormatTypes();
|
||||
$licenseTypes = $db->getAllLicenseTypes();
|
||||
|
||||
if (empty($orientations) || empty($apPrograms) || empty($finalityTypes)
|
||||
|| empty($languages) || empty($formatTypes) || empty($licenseTypes)) {
|
||||
throw new RuntimeException('Lookup tables empty — cannot build POST fixture');
|
||||
}
|
||||
|
||||
$base = [
|
||||
'titre' => 'Test TFE Title',
|
||||
'subtitle' => 'Test Subtitle',
|
||||
'auteurice' => 'Doe, Jane',
|
||||
'mail' => 'jane@example.com',
|
||||
'synopsis' => 'A short synopsis for testing purposes.',
|
||||
'année' => '2025',
|
||||
'orientation' => (string)$orientations[0]['id'],
|
||||
'ap' => (string)$apPrograms[0]['id'],
|
||||
'finality' => (string)$finalityTypes[0]['id'],
|
||||
'has_annexes' => '',
|
||||
'languages' => [(string)$languages[0]['id']],
|
||||
'language_autre' => '',
|
||||
'formats' => [(string)$formatTypes[0]['id']],
|
||||
'tag' => 'art, test, recherche',
|
||||
'jury_promoteur' => 'Prof. Smith',
|
||||
'jury_lecteur_interne' => ['Dr. Internal'],
|
||||
'jury_lecteur_externe' => ['Dr. External'],
|
||||
'license_id' => (string)$licenseTypes[0]['id'],
|
||||
'license_custom' => '',
|
||||
'access_type_id' => '2',
|
||||
'objet' => 'tfe',
|
||||
'lien' => '',
|
||||
'context_note' => '',
|
||||
'remarks' => '',
|
||||
'jury_points' => '',
|
||||
'exemplaire_baiu' => '',
|
||||
'exemplaire_erg' => '',
|
||||
'cc2r' => '',
|
||||
'is_published' => '',
|
||||
];
|
||||
|
||||
return array_merge($base, $overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert helper — throws on failure, echoes on pass.
|
||||
*/
|
||||
function assertEq(mixed $expected, mixed $actual, string $label): void
|
||||
{
|
||||
if ($expected == $actual) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
$e = var_export($expected, true);
|
||||
$a = var_export($actual, true);
|
||||
throw new RuntimeException("FAIL $label\n expected: $e\n actual: $a");
|
||||
}
|
||||
}
|
||||
|
||||
function assertContains(mixed $needle, array $haystack, string $label): void
|
||||
{
|
||||
if (in_array($needle, $haystack, false)) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
$a = implode(', ', $haystack);
|
||||
throw new RuntimeException("FAIL $label: $needle not in [$a]");
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotEmpty(mixed $value, string $label): void
|
||||
{
|
||||
if (!empty($value)) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
throw new RuntimeException("FAIL $label: value is empty");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test setup ────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "Form Save Round-Trip Test\n";
|
||||
echo "=========================\n\n";
|
||||
|
||||
$db = Database::getInstance();
|
||||
$createCtrl = new ThesisCreateController($db);
|
||||
$editCtrl = new ThesisEditController($db);
|
||||
|
||||
// Clean up stale leftovers from previous test runs
|
||||
$pdo = $db->getConnection();
|
||||
$stale = $pdo->query("SELECT id FROM theses WHERE title LIKE 'Round-trip test titre%' OR title LIKE 'Language%test%' OR title LIKE 'Backoffice fields test%' OR title LIKE 'Lang checkbox test%' OR title LIKE 'Context note test%'")->fetchAll(\PDO::FETCH_COLUMN);
|
||||
foreach ($stale as $id) {
|
||||
try {
|
||||
$db->deleteThesis((int)$id);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
$pdo->exec("DELETE FROM languages WHERE name LIKE 'TestLang%' OR name LIKE 'EditLang%' OR name LIKE 'Idempotent%'");
|
||||
|
||||
$createdIds = [];
|
||||
|
||||
try {
|
||||
|
||||
// =========================================================================
|
||||
// TEST 1: Create — basic fields persisted
|
||||
// =========================================================================
|
||||
echo "Test 1: Create — basic fields persisted\n";
|
||||
$uniq = bin2hex(random_bytes(4));
|
||||
$post = buildPost($db, [
|
||||
'titre' => 'Round-trip test titre ' . $uniq,
|
||||
'subtitle' => 'Round-trip subtitle',
|
||||
'synopsis' => 'Round-trip synopsis',
|
||||
'année' => '2025',
|
||||
'auteurice' => $uniq,
|
||||
'mail' => $uniq . '@example.com',
|
||||
]);
|
||||
|
||||
$thesisId = $createCtrl->submit($post, []);
|
||||
$createdIds[] = $thesisId;
|
||||
$row = $db->getThesis($thesisId);
|
||||
|
||||
assertEq('Round-trip test titre ' . $uniq, $row['title'], 'title saved');
|
||||
assertEq('Round-trip subtitle', $row['subtitle'], 'subtitle saved');
|
||||
assertEq('Round-trip synopsis', $row['synopsis'], 'synopsis saved');
|
||||
assertEq(2025, (int)$row['year'], 'year saved');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 2: Create — language_autre creates and links new language
|
||||
// =========================================================================
|
||||
echo "Test 2: Create — language_autre creates and links new language\n";
|
||||
$uniqueLang = 'TestLang_' . bin2hex(random_bytes(4));
|
||||
$post = buildPost($db, [
|
||||
'titre' => 'Language autre test',
|
||||
'languages' => [], // no checkbox
|
||||
'language_autre' => $uniqueLang,
|
||||
]);
|
||||
|
||||
$thesisId = $createCtrl->submit($post, []);
|
||||
$createdIds[] = $thesisId;
|
||||
|
||||
$langIds = $db->getThesisLanguageIds($thesisId);
|
||||
$allLangs = $db->getAllLanguages();
|
||||
$lowerLang = strtolower($uniqueLang);
|
||||
$found = array_filter($allLangs, fn ($l) => strtolower($l['name']) === $lowerLang);
|
||||
assertNotEmpty($found, "language '$uniqueLang' created in languages table");
|
||||
|
||||
$createdLangId = (int)array_values($found)[0]['id'];
|
||||
assertContains(
|
||||
(string)$createdLangId,
|
||||
array_map('strval', $langIds),
|
||||
'language_autre ID linked to thesis'
|
||||
);
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 3: Create — language_autre + checkbox together
|
||||
// =========================================================================
|
||||
echo "Test 5: Create — language_autre appended alongside checked languages\n";
|
||||
$db2 = Database::getInstance();
|
||||
$allLangs = $db2->getAllLanguages();
|
||||
$uniqueLang2 = 'TestLang2_' . bin2hex(random_bytes(4));
|
||||
$post = buildPost($db, [
|
||||
'titre' => 'Language combo test',
|
||||
'languages' => [(string)$allLangs[0]['id']],
|
||||
'language_autre' => $uniqueLang2,
|
||||
]);
|
||||
|
||||
$thesisId = $createCtrl->submit($post, []);
|
||||
$createdIds[] = $thesisId;
|
||||
$langIds = $db->getThesisLanguageIds($thesisId);
|
||||
|
||||
assertContains(
|
||||
(string)$allLangs[0]['id'],
|
||||
array_map('strval', $langIds),
|
||||
'checkbox language linked'
|
||||
);
|
||||
|
||||
$found2 = array_filter($db->getAllLanguages(), fn ($l) => strtolower($l['name']) === strtolower($uniqueLang2));
|
||||
$createdLang2 = (int)array_values($found2)[0]['id'];
|
||||
assertContains(
|
||||
(string)$createdLang2,
|
||||
array_map('strval', $langIds),
|
||||
'language_autre also linked'
|
||||
);
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 4: Edit — language checkboxes round-trip
|
||||
// =========================================================================
|
||||
echo "Test 3: Edit — language checkboxes round-trip\n";
|
||||
$allLangs = $db->getAllLanguages();
|
||||
$lang1 = (string)$allLangs[0]['id'];
|
||||
$lang2 = (string)$allLangs[1]['id'];
|
||||
|
||||
$post = buildPost($db, [
|
||||
'titre' => 'Lang checkbox test',
|
||||
'languages' => [$lang1],
|
||||
]);
|
||||
$thesisId = $createCtrl->submit($post, []);
|
||||
$createdIds[] = $thesisId;
|
||||
|
||||
$editPost = buildPost($db, [
|
||||
'titre' => 'Lang checkbox test',
|
||||
'languages' => [$lang1, $lang2],
|
||||
]);
|
||||
$editCtrl->save($thesisId, $editPost, []);
|
||||
$langIds = $db->getThesisLanguageIds($thesisId);
|
||||
|
||||
assertContains($lang1, array_map('strval', $langIds), 'first language retained on edit');
|
||||
assertContains($lang2, array_map('strval', $langIds), 'second language added on edit');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 5: Edit — language_autre adds new language
|
||||
// =========================================================================
|
||||
echo "Test 4: Edit — language_autre creates and links on edit\n";
|
||||
$uniqueLang3 = 'EditLang_' . bin2hex(random_bytes(4));
|
||||
$editPost = buildPost($db, [
|
||||
'titre' => 'Lang checkbox test',
|
||||
'languages' => [$lang1],
|
||||
'language_autre' => $uniqueLang3,
|
||||
]);
|
||||
$editCtrl->save($thesisId, $editPost, []);
|
||||
|
||||
$langIds = $db->getThesisLanguageIds($thesisId);
|
||||
$found3 = array_filter($db->getAllLanguages(), fn ($l) => strtolower($l['name']) === strtolower($uniqueLang3));
|
||||
assertNotEmpty($found3, "language '$uniqueLang3' created on edit");
|
||||
$createdLang3 = (int)array_values($found3)[0]['id'];
|
||||
assertContains(
|
||||
(string)$createdLang3,
|
||||
array_map('strval', $langIds),
|
||||
'language_autre linked on edit'
|
||||
);
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 6: Create — backoffice fields persisted
|
||||
// =========================================================================
|
||||
echo "Test 5: Create — backoffice fields (remarks, jury_points, exemplaires, cc2r)\n";
|
||||
$post = buildPost($db, [
|
||||
'titre' => 'Backoffice fields test',
|
||||
'remarks' => 'Internal note here',
|
||||
'jury_points' => '15.5',
|
||||
'exemplaire_baiu' => '1',
|
||||
'exemplaire_erg' => '1',
|
||||
'cc2r' => '1',
|
||||
]);
|
||||
|
||||
$thesisId = $createCtrl->submit($post, []);
|
||||
$createdIds[] = $thesisId;
|
||||
$raw = $db->getThesisRawFields($thesisId);
|
||||
|
||||
assertEq('Internal note here', $raw['remarks'], 'remarks saved');
|
||||
assertEq(15.5, (float)$raw['jury_points'], 'jury_points saved');
|
||||
assertEq(1, (int)$raw['exemplaire_baiu'], 'exemplaire_baiu saved');
|
||||
assertEq(1, (int)$raw['exemplaire_erg'], 'exemplaire_erg saved');
|
||||
assertEq(1, (int)$raw['cc2r'], 'cc2r saved');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 7: Edit — backoffice fields updated
|
||||
// =========================================================================
|
||||
echo "Test 6: Edit — backoffice fields updated\n";
|
||||
$editPost = buildPost($db, [
|
||||
'titre' => 'Backoffice fields test',
|
||||
'remarks' => 'Updated note',
|
||||
'jury_points' => '18',
|
||||
'exemplaire_baiu' => '',
|
||||
'exemplaire_erg' => '1',
|
||||
'cc2r' => '',
|
||||
]);
|
||||
$editCtrl->save($thesisId, $editPost, []);
|
||||
$raw = $db->getThesisRawFields($thesisId);
|
||||
|
||||
assertEq('Updated note', $raw['remarks'], 'remarks updated');
|
||||
assertEq(18.0, (float)$raw['jury_points'], 'jury_points updated');
|
||||
assertEq(0, (int)$raw['exemplaire_baiu'], 'exemplaire_baiu cleared');
|
||||
assertEq(1, (int)$raw['exemplaire_erg'], 'exemplaire_erg retained');
|
||||
assertEq(0, (int)$raw['cc2r'], 'cc2r cleared');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 8: getOrCreateLanguage — idempotent
|
||||
// =========================================================================
|
||||
echo "Test 7: getOrCreateLanguage — idempotent (same name returns same ID)\n";
|
||||
$uniqueName = 'Idempotent_' . bin2hex(random_bytes(4));
|
||||
$id1 = $db->getOrCreateLanguage($uniqueName);
|
||||
$id2 = $db->getOrCreateLanguage($uniqueName);
|
||||
$id3 = $db->getOrCreateLanguage(strtolower($uniqueName)); // case-insensitive
|
||||
|
||||
assertEq($id1, $id2, 'same ID on second call');
|
||||
assertEq($id1, $id3, 'same ID with different case');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 9: Edit — context_note saved
|
||||
// =========================================================================
|
||||
echo "Test 8: Edit — context_note saved\n";
|
||||
$post = buildPost($db, ['titre' => 'Context note test']);
|
||||
$thesisId = $createCtrl->submit($post, []);
|
||||
$createdIds[] = $thesisId;
|
||||
|
||||
$editPost = buildPost($db, [
|
||||
'titre' => 'Context note test',
|
||||
'context_note' => 'A contextual note visible publicly.',
|
||||
]);
|
||||
$editCtrl->save($thesisId, $editPost, []);
|
||||
$raw = $db->getThesisRawFields($thesisId);
|
||||
|
||||
assertEq('A contextual note visible publicly.', $raw['context_note'], 'context_note saved');
|
||||
echo "\n";
|
||||
|
||||
echo "✅ All form save tests passed!\n";
|
||||
$result = true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo '❌ FAIL: ' . $e->getMessage() . "\n";
|
||||
$result = false;
|
||||
} finally {
|
||||
// Clean up test theses
|
||||
foreach ($createdIds as $id) {
|
||||
try {
|
||||
$db->deleteThesis($id);
|
||||
} catch (Exception $e) { /* ignore */
|
||||
}
|
||||
}
|
||||
// Clean up test languages
|
||||
$allLangs = $db->getAllLanguages();
|
||||
foreach ($allLangs as $lang) {
|
||||
if (str_starts_with($lang['name'], 'TestLang_')
|
||||
|| str_starts_with($lang['name'], 'TestLang2_')
|
||||
|| str_starts_with($lang['name'], 'EditLang_')
|
||||
|| str_starts_with($lang['name'], 'Idempotent_')) {
|
||||
try {
|
||||
$db->getConnection()->prepare('DELETE FROM languages WHERE id = ?')->execute([$lang['id']]);
|
||||
} catch (Exception $e) { /* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result ?? false;
|
||||
@@ -1,377 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Pure Logic Unit Test
|
||||
*
|
||||
* Tests deterministic, I/O-free logic extracted from controllers:
|
||||
*
|
||||
* TfeController (via subclass to access protected methods):
|
||||
* - buildMetaDescription() — truncation at 160 chars, empty synopsis fallback
|
||||
* - resolveOgImage() — cover preferred over image, fallback chain
|
||||
* - splitJuryByRole() — correct binning of promoteur/lecteur/president/ulb
|
||||
* - collectCaptionPaths() — VTT files extracted in order
|
||||
*
|
||||
* ThesisCreateController (static / public helpers):
|
||||
* - autofocusFieldForError()
|
||||
* - detectFileType() — via subclass to access private
|
||||
* - generateAuthorSlug() — accent stripping, uppercase, underscore
|
||||
*
|
||||
* ExportController:
|
||||
* - CSV_HEADERS count matches exportAllTheses() row width
|
||||
*
|
||||
* SearchController:
|
||||
* - handleSearch() return array always contains 'coverMap' key (regression for undefined var bug)
|
||||
*/
|
||||
|
||||
putenv('DB_ENV=test');
|
||||
|
||||
if (!defined('APP_ROOT')) {
|
||||
define('APP_ROOT', dirname(__DIR__, 2));
|
||||
}
|
||||
if (!defined('STORAGE_ROOT')) {
|
||||
define('STORAGE_ROOT', APP_ROOT . '/storage');
|
||||
}
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/RateLimit.php';
|
||||
require_once APP_ROOT . '/src/Controllers/TfeController.php';
|
||||
require_once APP_ROOT . '/src/Controllers/ThesisCreateController.php';
|
||||
require_once APP_ROOT . '/src/Controllers/SearchController.php';
|
||||
require_once APP_ROOT . '/src/Controllers/ExportController.php';
|
||||
|
||||
// ── Test harness helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function plAssert(bool $cond, string $label): void
|
||||
{
|
||||
if ($cond) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
throw new RuntimeException("FAIL: $label");
|
||||
}
|
||||
}
|
||||
|
||||
function plAssertEq(mixed $expected, mixed $actual, string $label): void
|
||||
{
|
||||
if ($expected === $actual) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
$e = var_export($expected, true);
|
||||
$a = var_export($actual, true);
|
||||
throw new RuntimeException("FAIL $label\n expected: $e\n actual: $a");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subclasses to expose protected / private methods ─────────────────────────
|
||||
|
||||
class TfeControllerTestable extends TfeController
|
||||
{
|
||||
public function testBuildMetaDescription(string $synopsis): string
|
||||
{
|
||||
return $this->buildMetaDescription($synopsis);
|
||||
}
|
||||
|
||||
public function testResolveOgImage(array $files): string
|
||||
{
|
||||
return $this->resolveOgImage($files);
|
||||
}
|
||||
|
||||
public function testSplitJuryByRole(array $jury): array
|
||||
{
|
||||
return $this->splitJuryByRole($jury);
|
||||
}
|
||||
|
||||
public function testCollectCaptionPaths(array $files): array
|
||||
{
|
||||
return $this->collectCaptionPaths($files);
|
||||
}
|
||||
}
|
||||
|
||||
class ThesisCreateControllerTestable extends ThesisCreateController
|
||||
{
|
||||
public function testDetectFileType(string $mimeType, string $ext): string
|
||||
{
|
||||
return $this->detectFileType($mimeType, $ext);
|
||||
}
|
||||
|
||||
public function testGenerateAuthorSlug(string $name): string
|
||||
{
|
||||
return $this->generateAuthorSlug($name);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "Pure Logic Unit Test\n";
|
||||
echo "====================\n\n";
|
||||
|
||||
$db = Database::getInstance();
|
||||
$tfe = new TfeControllerTestable($db);
|
||||
$createCtrl = new ThesisCreateControllerTestable($db);
|
||||
|
||||
try {
|
||||
|
||||
// =========================================================================
|
||||
// SECTION A: TfeController helpers
|
||||
// =========================================================================
|
||||
|
||||
// ── A1: buildMetaDescription ──────────────────────────────────────────────
|
||||
echo "A1: buildMetaDescription — normal synopsis\n";
|
||||
$desc = $tfe->testBuildMetaDescription('A short synopsis.');
|
||||
plAssertEq('A short synopsis.', $desc, 'short synopsis returned as-is');
|
||||
echo "\n";
|
||||
|
||||
echo "A2: buildMetaDescription — synopsis over 160 chars truncated\n";
|
||||
$long = str_repeat('a', 200);
|
||||
$desc = $tfe->testBuildMetaDescription($long);
|
||||
plAssert(strlen($desc) <= 160, 'length <= 160');
|
||||
plAssert(str_ends_with($desc, '…'), 'ends with ellipsis');
|
||||
echo "\n";
|
||||
|
||||
echo "A3: buildMetaDescription — exactly 160 chars not truncated\n";
|
||||
$exact = str_repeat('b', 160);
|
||||
$desc = $tfe->testBuildMetaDescription($exact);
|
||||
plAssertEq($exact, $desc, '160-char synopsis not truncated');
|
||||
echo "\n";
|
||||
|
||||
echo "A4: buildMetaDescription — empty synopsis returns fallback\n";
|
||||
$desc = $tfe->testBuildMetaDescription('');
|
||||
plAssert($desc !== '', 'non-empty fallback returned');
|
||||
plAssert(strlen($desc) <= 160, 'fallback length <= 160');
|
||||
echo "\n";
|
||||
|
||||
echo "A5: buildMetaDescription — HTML tags stripped\n";
|
||||
$desc = $tfe->testBuildMetaDescription('<p>Hello <strong>world</strong></p>');
|
||||
plAssertEq('Hello world', $desc, 'HTML tags stripped');
|
||||
echo "\n";
|
||||
|
||||
// ── A6: resolveOgImage ────────────────────────────────────────────────────
|
||||
echo "A6: resolveOgImage — cover preferred over image files\n";
|
||||
$files = [
|
||||
['file_type' => 'image', 'file_path' => 'theses/2025/img.jpg'],
|
||||
['file_type' => 'cover', 'file_path' => 'covers/cover.jpg'],
|
||||
['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'],
|
||||
];
|
||||
$url = $tfe->testResolveOgImage($files);
|
||||
plAssert(str_contains($url, rawurlencode('covers/cover.jpg')), 'cover used when available');
|
||||
echo "\n";
|
||||
|
||||
echo "A7: resolveOgImage — falls back to first image when no cover\n";
|
||||
$files = [
|
||||
['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'],
|
||||
['file_type' => 'image', 'file_path' => 'theses/2025/img.jpg'],
|
||||
['file_type' => 'image', 'file_path' => 'theses/2025/img2.png'],
|
||||
];
|
||||
$url = $tfe->testResolveOgImage($files);
|
||||
plAssert(str_contains($url, rawurlencode('theses/2025/img.jpg')), 'first image file used as fallback');
|
||||
plAssert(!str_contains($url, rawurlencode('img2.png')), 'second image not used');
|
||||
echo "\n";
|
||||
|
||||
echo "A8: resolveOgImage — empty string when no image at all\n";
|
||||
$files = [
|
||||
['file_type' => 'main', 'file_path' => 'theses/2025/main.pdf'],
|
||||
['file_type' => 'audio', 'file_path' => 'theses/2025/audio.mp3'],
|
||||
];
|
||||
$url = $tfe->testResolveOgImage($files);
|
||||
plAssertEq('', $url, 'empty string when no image');
|
||||
echo "\n";
|
||||
|
||||
echo "A9: resolveOgImage — empty files array returns empty string\n";
|
||||
plAssertEq('', $tfe->testResolveOgImage([]), 'empty array → empty string');
|
||||
echo "\n";
|
||||
|
||||
// ── A10: splitJuryByRole ──────────────────────────────────────────────────
|
||||
echo "A10: splitJuryByRole — all roles correctly binned\n";
|
||||
$jury = [
|
||||
['name' => 'Alice', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0],
|
||||
['name' => 'Bob', 'role' => 'promoteur', 'is_external' => 0, 'is_ulb' => 0],
|
||||
['name' => 'Carol', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 1],
|
||||
['name' => 'Dave', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 0],
|
||||
['name' => 'Eve', 'role' => 'lecteur', 'is_external' => 0, 'is_ulb' => 0],
|
||||
['name' => 'Frank', 'role' => 'lecteur', 'is_external' => 1, 'is_ulb' => 0],
|
||||
];
|
||||
$split = $tfe->testSplitJuryByRole($jury);
|
||||
|
||||
plAssertEq(['Alice'], $split['presidents'], 'president');
|
||||
plAssertEq(['Bob'], $split['internes'], 'interne promoteur');
|
||||
plAssertEq(['Carol'], $split['ulb'], 'ulb promoteur');
|
||||
plAssertEq(['Dave'], $split['externes'], 'externe promoteur (non-ULB)');
|
||||
plAssertEq(['Eve'], $split['lecteurs_internes'], 'lecteur interne');
|
||||
plAssertEq(['Frank'], $split['lecteurs_externes'], 'lecteur externe');
|
||||
echo "\n";
|
||||
|
||||
echo "A11: splitJuryByRole — empty name skipped\n";
|
||||
$jury = [['name' => '', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0]];
|
||||
$split = $tfe->testSplitJuryByRole($jury);
|
||||
plAssertEq([], $split['presidents'], 'empty name not added');
|
||||
echo "\n";
|
||||
|
||||
echo "A12: splitJuryByRole — empty jury returns all-empty arrays\n";
|
||||
$split = $tfe->testSplitJuryByRole([]);
|
||||
plAssertEq([], $split['presidents'], 'presidents empty');
|
||||
plAssertEq([], $split['internes'], 'internes empty');
|
||||
plAssertEq([], $split['ulb'], 'ulb empty');
|
||||
plAssertEq([], $split['externes'], 'externes empty');
|
||||
plAssertEq([], $split['lecteurs_internes'], 'lecteurs_internes empty');
|
||||
plAssertEq([], $split['lecteurs_externes'], 'lecteurs_externes empty');
|
||||
echo "\n";
|
||||
|
||||
// ── A13: collectCaptionPaths ──────────────────────────────────────────────
|
||||
echo "A13: collectCaptionPaths — VTT files extracted in order\n";
|
||||
$files = [
|
||||
['mime_type' => 'application/pdf', 'file_path' => 'main.pdf'],
|
||||
['mime_type' => 'text/vtt', 'file_path' => 'captions1.vtt'],
|
||||
['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'],
|
||||
['mime_type' => 'text/plain', 'file_path' => 'captions2.vtt'],
|
||||
];
|
||||
$captions = $tfe->testCollectCaptionPaths($files);
|
||||
plAssertEq(['captions1.vtt', 'captions2.vtt'], $captions, 'both VTT files returned in order');
|
||||
echo "\n";
|
||||
|
||||
echo "A14: collectCaptionPaths — .vtt extension without mime match\n";
|
||||
$files = [
|
||||
['mime_type' => 'application/octet-stream', 'file_path' => 'sub.vtt'],
|
||||
['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'],
|
||||
];
|
||||
$captions = $tfe->testCollectCaptionPaths($files);
|
||||
plAssertEq(['sub.vtt'], $captions, '.vtt extension matches even with generic mime');
|
||||
echo "\n";
|
||||
|
||||
echo "A15: collectCaptionPaths — no VTT returns empty array\n";
|
||||
$files = [
|
||||
['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'],
|
||||
];
|
||||
plAssertEq([], $tfe->testCollectCaptionPaths($files), 'empty array when no VTT');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION B: ThesisCreateController helpers
|
||||
// =========================================================================
|
||||
|
||||
// ── B1: autofocusFieldForError ────────────────────────────────────────────
|
||||
echo "B1: autofocusFieldForError — known error messages map to fields\n";
|
||||
$cases = [
|
||||
['Titre du TFE', 'titre'],
|
||||
["Le champ 'Auteur·ice(s)' est requis.", 'auteurice'],
|
||||
['Synopsis', 'synopsis'],
|
||||
['Année invalide', 'année'],
|
||||
['orientation', 'orientation'],
|
||||
['Atelier Pratique', 'ap'],
|
||||
['finalité', 'finality'],
|
||||
['langue', 'languages'],
|
||||
['promoteur', 'jury_promoteur'],
|
||||
['lecteur·ice interne', 'jury_lecteur_interne[]'],
|
||||
['lecteur·ice externe', 'jury_lecteur_externe[]'],
|
||||
['format', 'formats'],
|
||||
['licence', 'license_id'],
|
||||
['Lien URL', 'lien'],
|
||||
];
|
||||
foreach ($cases as [$message, $expected]) {
|
||||
$actual = ThesisCreateController::autofocusFieldForError($message);
|
||||
plAssertEq($expected, $actual, "\"$message\" → $expected");
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
echo "B2: autofocusFieldForError — unknown message returns null\n";
|
||||
plAssertEq(null, ThesisCreateController::autofocusFieldForError('completely unknown'), 'null for unknown');
|
||||
echo "\n";
|
||||
|
||||
// ── B3: detectFileType ────────────────────────────────────────────────────
|
||||
echo "B3: detectFileType — mime-based detection\n";
|
||||
$cases = [
|
||||
['text/vtt', 'vtt', 'caption'],
|
||||
['audio/mpeg', 'mp3', 'audio'],
|
||||
['audio/ogg', 'ogg', 'audio'],
|
||||
['video/mp4', 'mp4', 'video'],
|
||||
['video/webm', 'webm', 'video'],
|
||||
['application/pdf', 'pdf', 'main'],
|
||||
['image/jpeg', 'jpg', 'image'],
|
||||
['image/png', 'png', 'image'],
|
||||
['application/zip', 'zip', 'other'],
|
||||
];
|
||||
foreach ($cases as [$mime, $ext, $expected]) {
|
||||
$actual = $createCtrl->testDetectFileType($mime, $ext);
|
||||
plAssertEq($expected, $actual, "$mime / $ext → $expected");
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
echo "B4: detectFileType — extension-based fallback\n";
|
||||
// application/octet-stream with known extensions
|
||||
$cases = [
|
||||
['application/octet-stream', 'mp3', 'audio'],
|
||||
['application/octet-stream', 'mp4', 'video'],
|
||||
['application/octet-stream', 'pdf', 'main'],
|
||||
['application/octet-stream', 'webp', 'image'],
|
||||
['application/octet-stream', 'vtt', 'caption'],
|
||||
];
|
||||
foreach ($cases as [$mime, $ext, $expected]) {
|
||||
$actual = $createCtrl->testDetectFileType($mime, $ext);
|
||||
plAssertEq($expected, $actual, "octet-stream + .$ext → $expected");
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// ── B5: generateAuthorSlug ────────────────────────────────────────────────
|
||||
echo "B5: generateAuthorSlug — basic ASCII\n";
|
||||
plAssertEq('JANE_DOE', $createCtrl->testGenerateAuthorSlug('Jane Doe'), 'spaces to underscores, uppercase');
|
||||
plAssertEq('AUTHOR', $createCtrl->testGenerateAuthorSlug(''), 'empty → AUTHOR');
|
||||
echo "\n";
|
||||
|
||||
echo "B6: generateAuthorSlug — French accents stripped\n";
|
||||
plAssertEq('ELEONORE_DUPONT', $createCtrl->testGenerateAuthorSlug('Éléonore Dupont'), 'accents stripped');
|
||||
plAssertEq('FRANCOISE', $createCtrl->testGenerateAuthorSlug('Françoise'), 'ç → C');
|
||||
echo "\n";
|
||||
|
||||
echo "B7: generateAuthorSlug — multiple authors comma-separated\n";
|
||||
$slug = $createCtrl->testGenerateAuthorSlug('Alice Martin, Bob Durand');
|
||||
plAssert(str_contains($slug, 'ALICE'), 'contains ALICE');
|
||||
plAssert(str_contains($slug, 'BOB'), 'contains BOB');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION C: ExportController — CSV column count consistency
|
||||
// =========================================================================
|
||||
|
||||
echo "C1: ExportController — CSV_HEADERS count matches row column count\n";
|
||||
$export = new ExportController($db);
|
||||
$rows = $export->exportAllTheses();
|
||||
$headerCount = count(ExportController::CSV_HEADERS);
|
||||
|
||||
plAssert($headerCount > 0, 'CSV_HEADERS is non-empty');
|
||||
|
||||
if (!empty($rows)) {
|
||||
foreach ($rows as $i => $row) {
|
||||
if (count($row) !== $headerCount) {
|
||||
throw new RuntimeException(
|
||||
"FAIL: row $i has " . count($row) . " columns, expected $headerCount"
|
||||
);
|
||||
}
|
||||
}
|
||||
echo ' ✓ all ' . count($rows) . " rows have $headerCount columns matching CSV_HEADERS\n";
|
||||
} else {
|
||||
echo " ✓ no rows to check (empty export) — header count is $headerCount\n";
|
||||
}
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// SECTION D: SearchController — coverMap key always present (regression)
|
||||
// =========================================================================
|
||||
|
||||
echo "D1: SearchController::handleSearch() — coverMap key always in return array\n";
|
||||
// Simulate $_GET for the method (it reads from $_GET directly via collectSearchParams)
|
||||
$_GET = ['query' => ''];
|
||||
$rateLimit = new RateLimit(1000, 60);
|
||||
$searchCtrl = new SearchController($db, $rateLimit);
|
||||
$vars = $searchCtrl->handleSearch();
|
||||
plAssert(array_key_exists('coverMap', $vars), 'coverMap key present in handleSearch() return');
|
||||
plAssert(is_array($vars['coverMap']), 'coverMap is an array');
|
||||
$_GET = [];
|
||||
echo "\n";
|
||||
|
||||
echo "✅ All pure logic tests passed!\n";
|
||||
$result = true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo '❌ FAIL: ' . $e->getMessage() . "\n";
|
||||
$result = false;
|
||||
}
|
||||
|
||||
return $result ?? false;
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Rate Limit Test
|
||||
* Tests rate limiting functionality
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../src/RateLimit.php';
|
||||
|
||||
echo "Rate Limit Test\n";
|
||||
echo "===============\n\n";
|
||||
|
||||
try {
|
||||
// Test 1: Rate limit initialization
|
||||
echo "Test 1: Rate Limit Initialization\n";
|
||||
$rateLimit = new RateLimit(5, 60); // 5 requests per minute
|
||||
echo "✓ PASS: RateLimit object created\n\n";
|
||||
|
||||
// Test 2: Check method exists and works
|
||||
echo "Test 2: Check Method\n";
|
||||
$allowed = $rateLimit->check();
|
||||
if (is_bool($allowed)) {
|
||||
echo '✓ PASS: check() returns boolean (allowed: ' . ($allowed ? 'yes' : 'no') . ")\n\n";
|
||||
} else {
|
||||
throw new Exception('check() did not return boolean');
|
||||
}
|
||||
|
||||
// Test 3: Headers method
|
||||
echo "Test 3: Send Headers Method\n";
|
||||
ob_start();
|
||||
$rateLimit->sendHeaders();
|
||||
ob_end_clean();
|
||||
echo "✓ PASS: sendHeaders() executed without error\n\n";
|
||||
|
||||
// Test 4: Get reset time
|
||||
echo "Test 4: Get Reset Time\n";
|
||||
$resetTime = $rateLimit->getResetTime();
|
||||
if (is_int($resetTime) && $resetTime >= 0) {
|
||||
echo "✓ PASS: getResetTime() returns valid value ($resetTime seconds)\n\n";
|
||||
} else {
|
||||
throw new Exception('Invalid reset time');
|
||||
}
|
||||
|
||||
// Test 5: Cleanup method
|
||||
echo "Test 5: Cleanup Method\n";
|
||||
$rateLimit->cleanup();
|
||||
echo "✓ PASS: cleanup() executed without error\n\n";
|
||||
|
||||
echo "✅ All rate limit tests passed!\n";
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo '❌ FAIL: ' . $e->getMessage() . "\n";
|
||||
return false;
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ShareLink Unit Test
|
||||
*
|
||||
* Tests pure-logic methods that require no HTTP context:
|
||||
* - generateSlug() — format, uniqueness, entropy
|
||||
* - validateLink() — all branches: not_found, archived, disabled, expired, needs_password, valid
|
||||
* - verifyPassword() — correct / wrong / no-password links
|
||||
*/
|
||||
|
||||
putenv('DB_ENV=test');
|
||||
|
||||
if (!defined('APP_ROOT')) {
|
||||
define('APP_ROOT', dirname(__DIR__, 2));
|
||||
}
|
||||
if (!defined('STORAGE_ROOT')) {
|
||||
define('STORAGE_ROOT', APP_ROOT . '/storage');
|
||||
}
|
||||
|
||||
require_once APP_ROOT . '/src/Database.php';
|
||||
require_once APP_ROOT . '/src/ShareLink.php';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function slAssert(bool $cond, string $label): void
|
||||
{
|
||||
if ($cond) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
throw new RuntimeException("FAIL: $label");
|
||||
}
|
||||
}
|
||||
|
||||
function slAssertEq(mixed $expected, mixed $actual, string $label): void
|
||||
{
|
||||
if ($expected === $actual) {
|
||||
echo " ✓ $label\n";
|
||||
} else {
|
||||
$e = var_export($expected, true);
|
||||
$a = var_export($actual, true);
|
||||
throw new RuntimeException("FAIL $label\n expected: $e\n actual: $a");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "ShareLink Unit Test\n";
|
||||
echo "===================\n\n";
|
||||
|
||||
$db = Database::getInstance();
|
||||
$model = new ShareLink($db);
|
||||
|
||||
// We need a dummy admin user id — just use 1 (or any int; share_links.created_by is not FK-checked)
|
||||
$adminId = 1;
|
||||
$createdIds = [];
|
||||
|
||||
try {
|
||||
|
||||
// =========================================================================
|
||||
// TEST 1: generateSlug — format YYYYMMDD-XXXXXXXX
|
||||
// =========================================================================
|
||||
echo "Test 1: generateSlug — format\n";
|
||||
$slug = ShareLink::generateSlug();
|
||||
slAssert(
|
||||
(bool) preg_match('/^\d{8}-[A-Z2-7]{8}$/', $slug),
|
||||
"slug matches YYYYMMDD-[BASE32]{8}: $slug"
|
||||
);
|
||||
$year = (int) substr($slug, 0, 4);
|
||||
$month = (int) substr($slug, 4, 2);
|
||||
$day = (int) substr($slug, 6, 2);
|
||||
slAssert($year >= 2020 && $year <= 2100, 'year in plausible range');
|
||||
slAssert($month >= 1 && $month <= 12, 'month in range');
|
||||
slAssert($day >= 1 && $day <= 31, 'day in range');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 2: generateSlug — two calls produce different slugs
|
||||
// =========================================================================
|
||||
echo "Test 2: generateSlug — uniqueness\n";
|
||||
$slugs = [];
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$slugs[] = ShareLink::generateSlug();
|
||||
}
|
||||
slAssertEq(count($slugs), count(array_unique($slugs)), '20 consecutive slugs are all unique');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 3: validateLink — not_found on missing slug
|
||||
// =========================================================================
|
||||
echo "Test 3: validateLink — not_found on missing slug\n";
|
||||
$result = $model->validateLink('NONEXISTENT-SLUG');
|
||||
slAssertEq(false, $result['valid'], 'valid=false');
|
||||
slAssertEq('not_found', $result['reason'], 'reason=not_found');
|
||||
|
||||
$result = $model->validateLink(null);
|
||||
slAssertEq(false, $result['valid'], 'null slug: valid=false');
|
||||
slAssertEq('not_found', $result['reason'], 'null slug: reason=not_found');
|
||||
|
||||
$result = $model->validateLink('');
|
||||
slAssertEq(false, $result['valid'], 'empty slug: valid=false');
|
||||
slAssertEq('not_found', $result['reason'], 'empty slug: reason=not_found');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 4: validateLink — valid active link with no password
|
||||
// =========================================================================
|
||||
echo "Test 4: validateLink — link with auto-generated password needs password\n";
|
||||
$link = $model->create($adminId, null, null);
|
||||
$createdIds[] = $link['id'];
|
||||
|
||||
$result = $model->validateLink($link['slug']);
|
||||
slAssertEq(false, $result['valid'], 'valid=false (has auto-generated password)');
|
||||
slAssertEq('needs_password', $result['reason'], 'reason=needs_password');
|
||||
slAssert(isset($result['link']), 'link row returned');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 5: validateLink — disabled link
|
||||
// =========================================================================
|
||||
echo "Test 5: validateLink — disabled link\n";
|
||||
$model->toggleActive($link['id']); // deactivate
|
||||
$result = $model->validateLink($link['slug']);
|
||||
slAssertEq(false, $result['valid'], 'valid=false after disable');
|
||||
slAssertEq('disabled', $result['reason'], 'reason=disabled');
|
||||
$model->toggleActive($link['id']); // restore
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 6: validateLink — archived link
|
||||
// =========================================================================
|
||||
echo "Test 6: validateLink — archived link\n";
|
||||
$archivedLink = $model->create($adminId, null, null);
|
||||
$createdIds[] = $archivedLink['id'];
|
||||
$model->archive($archivedLink['id']);
|
||||
$result = $model->validateLink($archivedLink['slug']);
|
||||
slAssertEq(false, $result['valid'], 'valid=false for archived');
|
||||
slAssertEq('archived', $result['reason'], 'reason=archived');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 7: validateLink — expired link (needs_password takes priority)
|
||||
// =========================================================================
|
||||
echo "Test 7: validateLink — expired link with password\n";
|
||||
$pastDate = date('Y-m-d H:i:s', strtotime('-1 day'));
|
||||
$expiredLink = $model->create($adminId, $pastDate);
|
||||
$createdIds[] = $expiredLink['id'];
|
||||
$result = $model->validateLink($expiredLink['slug']);
|
||||
slAssertEq(false, $result['valid'], 'valid=false');
|
||||
slAssertEq('expired', $result['reason'], 'reason=expired');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 8: validateLink — needs_password (all links have passwords now)
|
||||
// =========================================================================
|
||||
echo "Test 8: validateLink — needs_password\n";
|
||||
$pwLink = $model->create($adminId, null);
|
||||
$createdIds[] = $pwLink['id'];
|
||||
$result = $model->validateLink($pwLink['slug']);
|
||||
slAssertEq(false, $result['valid'], 'valid=false (needs password)');
|
||||
slAssertEq('needs_password', $result['reason'], 'reason=needs_password');
|
||||
slAssert(isset($result['link']), 'link row returned even when password needed');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 9: verifyPassword — correct auto-generated password
|
||||
// =========================================================================
|
||||
echo "Test 9: verifyPassword — correct auto-generated password\n";
|
||||
$pwLinkRow = $model->findBySlug($pwLink['slug']);
|
||||
$plainPassword = $pwLink['_plain_password'] ?? '';
|
||||
slAssert($plainPassword !== '', 'auto-generated password is non-empty');
|
||||
slAssertEq(true, $model->verifyPassword($pwLinkRow, $plainPassword), 'correct password accepted');
|
||||
slAssertEq(false, $model->verifyPassword($pwLinkRow, 'wrongpass'), 'wrong password rejected');
|
||||
slAssertEq(false, $model->verifyPassword($pwLinkRow, ''), 'empty password rejected');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 10: verifyPassword — any link requires correct password
|
||||
// =========================================================================
|
||||
echo "Test 10: verifyPassword — wrong password rejected\n";
|
||||
$anyLinkRow = $model->findBySlug($link['slug']);
|
||||
slAssertEq(false, $model->verifyPassword($anyLinkRow, ''), 'empty string rejected');
|
||||
slAssertEq(false, $model->verifyPassword($anyLinkRow, 'anything'), 'random string rejected');
|
||||
slAssertEq(true, $model->verifyPassword($anyLinkRow, $link['_plain_password'] ?? ''), 'correct password accepted');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 12: incrementUsage — counter goes up
|
||||
// =========================================================================
|
||||
echo "Test 12: incrementUsage — counter increments\n";
|
||||
$fresh = $model->findById($link['id']);
|
||||
$before = (int)$fresh['usage_count'];
|
||||
$model->incrementUsage($link['id']);
|
||||
$model->incrementUsage($link['id']);
|
||||
$after = (int)($model->findById($link['id'])['usage_count'] ?? 0);
|
||||
slAssertEq($before + 2, $after, 'usage_count incremented by 2');
|
||||
echo "\n";
|
||||
|
||||
// =========================================================================
|
||||
// TEST 13: objet_restriction is stored and returned
|
||||
// =========================================================================
|
||||
echo "Test 13: objet_restriction stored correctly\n";
|
||||
$restrictedLink = $model->create($adminId, null, null, 'tfe');
|
||||
$createdIds[] = $restrictedLink['id'];
|
||||
slAssertEq('tfe', $restrictedLink['objet_restriction'], 'objet_restriction=tfe stored');
|
||||
|
||||
$anyLink = $model->create($adminId, null, null, 'invalid_value');
|
||||
$createdIds[] = $anyLink['id'];
|
||||
slAssertEq('tfe', $anyLink['objet_restriction'], 'invalid objet_restriction defaults to tfe');
|
||||
echo "\n";
|
||||
|
||||
echo "✅ All ShareLink tests passed!\n";
|
||||
$result = true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
echo '❌ FAIL: ' . $e->getMessage() . "\n";
|
||||
$result = false;
|
||||
} finally {
|
||||
$pdo = $db->getConnection();
|
||||
foreach ($createdIds as $id) {
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM share_links WHERE id = ?')->execute([$id]);
|
||||
} catch (Exception $e) { /* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result ?? false;
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* XAMXAM Test Runner
|
||||
* Runs all tests in the tests/ directory
|
||||
*/
|
||||
|
||||
echo "╔════════════════════════════════════════════╗\n";
|
||||
echo "║ XAMXAM Test Suite ║\n";
|
||||
echo "╚════════════════════════════════════════════╝\n\n";
|
||||
|
||||
$testFiles = [
|
||||
['name' => 'Database (Unit)', 'path' => __DIR__ . '/Unit/DatabaseTest.php'],
|
||||
['name' => 'Rate Limit (Unit)', 'path' => __DIR__ . '/Unit/RateLimitTest.php'],
|
||||
['name' => 'Form Save Round-Trip (Unit)', 'path' => __DIR__ . '/Unit/FormSaveTest.php'],
|
||||
['name' => 'ShareLink (Unit)', 'path' => __DIR__ . '/Unit/ShareLinkTest.php'],
|
||||
['name' => 'ErrorHandler (Unit)', 'path' => __DIR__ . '/Unit/ErrorHandlerTest.php'],
|
||||
['name' => 'Pure Logic (Unit)', 'path' => __DIR__ . '/Unit/PureLogicTest.php'],
|
||||
['name' => 'Search (Integration)', 'path' => __DIR__ . '/Integration/SearchTest.php'],
|
||||
['name' => 'Security', 'path' => __DIR__ . '/Security/SecurityTest.php'],
|
||||
];
|
||||
|
||||
$totalTests = 0;
|
||||
$passedTests = 0;
|
||||
$failedTests = 0;
|
||||
$skippedTests = 0;
|
||||
|
||||
foreach ($testFiles as $test) {
|
||||
echo "┌─────────────────────────────────────────┐\n";
|
||||
echo '│ ' . str_pad($test['name'], 41) . "│\n";
|
||||
echo "└─────────────────────────────────────────┘\n\n";
|
||||
|
||||
$totalTests++;
|
||||
$path = $test['path'];
|
||||
$file = basename($path);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
echo "⚠️ SKIP: $file (not found)\n\n";
|
||||
$skippedTests++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ob_start();
|
||||
$exitCode = 0;
|
||||
$testResult = false;
|
||||
|
||||
try {
|
||||
$testResult = include $path;
|
||||
|
||||
// Check if test returned false or had error indicators in output
|
||||
$output = ob_get_contents();
|
||||
if ($testResult === false ||
|
||||
strpos($output, '❌') !== false ||
|
||||
strpos($output, 'FAIL:') !== false) {
|
||||
$exitCode = 1;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$exitCode = 1;
|
||||
echo '❌ EXCEPTION: ' . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
$output = ob_get_clean();
|
||||
echo $output;
|
||||
|
||||
if ($exitCode === 0 && $testResult !== false) {
|
||||
echo "\n✅ TEST PASSED\n\n";
|
||||
$passedTests++;
|
||||
} else {
|
||||
echo "\n❌ TEST FAILED\n\n";
|
||||
$failedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "╔════════════════════════════════════════════╗\n";
|
||||
echo "║ Test Summary ║\n";
|
||||
echo "╠════════════════════════════════════════════╣\n";
|
||||
echo '║ Total: ' . str_pad($totalTests, 34) . "║\n";
|
||||
echo '║ Passed: ' . str_pad($passedTests . ' ✅', 35) . "║\n";
|
||||
echo '║ Failed: ' . str_pad($failedTests . ($failedTests > 0 ? ' ❌' : ''), 35) . "║\n";
|
||||
if ($skippedTests > 0) {
|
||||
echo '║ Skipped: ' . str_pad($skippedTests . ' ⚠️', 36) . "║\n";
|
||||
}
|
||||
echo "╚════════════════════════════════════════════╝\n\n";
|
||||
|
||||
if ($failedTests > 0) {
|
||||
echo "❌ Some tests failed!\n";
|
||||
exit(1);
|
||||
} else {
|
||||
echo "✅ All tests passed!\n";
|
||||
exit(0);
|
||||
}
|
||||
11
justfile
11
justfile
@@ -339,10 +339,13 @@ deploy-all-first: deploy deploy-backup
|
||||
|
||||
[group('test')]
|
||||
test:
|
||||
# Run all tests. To run a subset, use:
|
||||
# php app/tests/Unit/DatabaseTest.php
|
||||
# php app/tests/Integration/SearchTest.php
|
||||
@php app/tests/run-tests.php
|
||||
# Run all PHPUnit tests
|
||||
@vendor/bin/phpunit tests/phpunit/
|
||||
|
||||
[group('test')]
|
||||
test-coverage:
|
||||
# Generate HTML coverage report in coverage/
|
||||
@vendor/bin/phpunit --coverage-html coverage/ tests/phpunit/
|
||||
|
||||
[group('test')]
|
||||
lint-biome:
|
||||
|
||||
265
tests/phpunit/ErrorHandlerTest.php
Normal file
265
tests/phpunit/ErrorHandlerTest.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* ErrorHandlerTest — PHPUnit version of the old custom-runner ErrorHandler test.
|
||||
*
|
||||
* Covers ErrorHandler::userMessage() FK constraint parsing, UNIQUE/NOT NULL
|
||||
* handling, domain exception pass-through, generic fallback, and log().
|
||||
*/
|
||||
class ErrorHandlerTest extends TestCase
|
||||
{
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function makeFkException(string $sqliteMessage): PDOException
|
||||
{
|
||||
return new PDOException($sqliteMessage, 787);
|
||||
}
|
||||
|
||||
// ── FK constraint: INSERT INTO pattern ───────────────────────────────────
|
||||
|
||||
public function testFkThesesTableMentionsAllPossibleFields(): void
|
||||
{
|
||||
$msg = $this->makeFkException(
|
||||
'FOREIGN KEY constraint failed INSERT INTO theses (title, orientation_id) VALUES (?,?)'
|
||||
);
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Orientation', $user);
|
||||
$this->assertStringContainsString('AP', $user);
|
||||
$this->assertStringContainsString('Licence', $user);
|
||||
$this->assertStringNotContainsString('FOREIGN KEY', $user);
|
||||
}
|
||||
|
||||
public function testFkApPrograms(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (ap_program_id) VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('AP', $user);
|
||||
}
|
||||
|
||||
public function testFkFinalityTypes(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (finality_id) VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Finalité', $user);
|
||||
}
|
||||
|
||||
public function testFkThesisLanguages(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Langue(s)', $user);
|
||||
}
|
||||
|
||||
public function testFkThesisFormats(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Format(s)', $user);
|
||||
}
|
||||
|
||||
public function testFkThesisTags(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_tags (thesis_id, tag_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Mots-clés', $user);
|
||||
}
|
||||
|
||||
public function testFkThesisSupervisors(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_supervisors (thesis_id, supervisor_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Composition du jury', $user);
|
||||
}
|
||||
|
||||
public function testFkAccessTypes(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (access_type_id) VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString("Type d'accès", $user);
|
||||
}
|
||||
|
||||
public function testFkLicenseTypes(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO theses (license_id) VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Licence', $user);
|
||||
}
|
||||
|
||||
public function testFkAuthors(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_authors (thesis_id, author_id) VALUES (?,?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Auteur·ice', $user);
|
||||
}
|
||||
|
||||
// ── FK constraint: "table" pattern (SQLite 3.37+) ────────────────────────
|
||||
|
||||
public function testFkQuotedTableName(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed (table "orientations")');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Orientation', $user);
|
||||
}
|
||||
|
||||
public function testFkQuotedLanguages(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed (table "languages")');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Langue(s)', $user);
|
||||
}
|
||||
|
||||
public function testFkQuotedFormatTypes(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed (table "format_types")');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Format(s)', $user);
|
||||
}
|
||||
|
||||
// ── FK constraint: REFERENCES pattern ────────────────────────────────────
|
||||
|
||||
public function testFkReferencesTags(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed (REFERENCES tags(id))');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Mots-clés', $user);
|
||||
}
|
||||
|
||||
public function testFkReferencesOrientations(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed (REFERENCES orientations(id))');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Orientation', $user);
|
||||
}
|
||||
|
||||
// ── FK constraint: unknown table → generic ───────────────────────────────
|
||||
|
||||
public function testFkUnknownTableGenericFallback(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO unknown_table VALUES (?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('contrainte de référence est invalide', $user);
|
||||
$this->assertStringNotContainsString('unknown_table', $user);
|
||||
}
|
||||
|
||||
public function testFkEmptyMessageGenericFallback(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('contrainte de référence est invalide', $user);
|
||||
}
|
||||
|
||||
// ── UNIQUE constraint ────────────────────────────────────────────────────
|
||||
|
||||
public function testUniqueConstraint(): void
|
||||
{
|
||||
$msg = new PDOException('UNIQUE constraint failed: thesis_tags.tag_id, thesis_tags.thesis_id', 2067);
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('valeur en double', $user);
|
||||
$this->assertStringNotContainsString('UNIQUE', $user);
|
||||
$this->assertStringNotContainsString('thesis_tags', $user);
|
||||
}
|
||||
|
||||
// ── NOT NULL constraint ──────────────────────────────────────────────────
|
||||
|
||||
public function testNotNullConstraint(): void
|
||||
{
|
||||
$msg = new PDOException('NOT NULL constraint failed: theses.title', 1299);
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('champ obligatoire est manquant', $user);
|
||||
$this->assertStringNotContainsString('NOT NULL', $user);
|
||||
}
|
||||
|
||||
// ── Generic PDO error ────────────────────────────────────────────────────
|
||||
|
||||
public function testGenericPdoError(): void
|
||||
{
|
||||
$msg = new PDOException('database disk image is malformed', 11);
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Une erreur de base de données est survenue', $user);
|
||||
$this->assertStringNotContainsString('disk image', $user);
|
||||
}
|
||||
|
||||
// ── Domain exceptions pass through ───────────────────────────────────────
|
||||
|
||||
public function testDuplicateThesisExceptionPassesThrough(): void
|
||||
{
|
||||
$dup = new DuplicateThesisException(42, '2025-ABC12345', 'Test titre', 'Auteur', 2025);
|
||||
$user = ErrorHandler::userMessage($dup);
|
||||
$this->assertStringContainsString('2025-ABC12345', $user);
|
||||
$this->assertStringContainsString('Auteur', $user);
|
||||
}
|
||||
|
||||
public function testValidationExceptionPassesThrough(): void
|
||||
{
|
||||
$val = new RuntimeException('Le titre est requis.');
|
||||
$user = ErrorHandler::userMessage($val);
|
||||
$this->assertSame('Le titre est requis.', $user);
|
||||
}
|
||||
|
||||
// ── Unknown exception types → generic fallback ───────────────────────────
|
||||
|
||||
public function testGenericExceptionPassesThrough(): void
|
||||
{
|
||||
$gen = new Exception('Something went wrong');
|
||||
$user = ErrorHandler::userMessage($gen);
|
||||
$this->assertStringContainsString('Something went wrong', $user);
|
||||
}
|
||||
|
||||
public function testTypeErrorReturnsGeneric(): void
|
||||
{
|
||||
$typeErr = new TypeError('htmlspecialchars(): Argument #1 must be string, array given');
|
||||
$user = ErrorHandler::userMessage($typeErr);
|
||||
$this->assertStringContainsString('Une erreur inattendue est survenue', $user);
|
||||
$this->assertStringNotContainsString('htmlspecialchars', $user);
|
||||
}
|
||||
|
||||
// ── log() does not crash ─────────────────────────────────────────────────
|
||||
|
||||
public function testLogWithContext(): void
|
||||
{
|
||||
// Should not throw
|
||||
ErrorHandler::log('test_context', new Exception('test message'), [
|
||||
'thesis_id' => 42,
|
||||
'slug' => '20250101-TEST1234',
|
||||
]);
|
||||
$this->assertTrue(true); // reached here = no crash
|
||||
}
|
||||
|
||||
public function testLogWithNullValues(): void
|
||||
{
|
||||
ErrorHandler::log('test_null', new PDOException('test'), ['id' => null, 'name' => 'foo']);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testLogWithEmptyExtra(): void
|
||||
{
|
||||
ErrorHandler::log('test_empty', new RuntimeException('bare'));
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ── Real-world FK error patterns ─────────────────────────────────────────
|
||||
|
||||
public function testFkQuotedColumnNames(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats ("thesis_id", "format_id") VALUES (?, ?)');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Format(s)', $user);
|
||||
}
|
||||
|
||||
public function testFkUpdateStatement(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed UPDATE theses SET orientation_id = ? WHERE id = ?');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
$this->assertStringContainsString('Orientation', $user);
|
||||
}
|
||||
|
||||
public function testFkWithReferencesAndInsert(): void
|
||||
{
|
||||
$msg = $this->makeFkException('FOREIGN KEY constraint failed INSERT INTO thesis_formats (thesis_id, format_id) VALUES (?, ?) REFERENCES format_types');
|
||||
$user = ErrorHandler::userMessage($msg);
|
||||
// First matcher wins (INSERT table)
|
||||
$this->assertStringContainsString('Format(s)', $user);
|
||||
}
|
||||
}
|
||||
144
tests/phpunit/PureLogicTest.php
Normal file
144
tests/phpunit/PureLogicTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* PureLogicTest — PHPUnit version covering pure logic that isn't in other test classes.
|
||||
*
|
||||
* Includes: splitJuryByRole, collectCaptionPaths, detectFileType,
|
||||
* generateAuthorSlug, ExportController CSV_HEADERS consistency.
|
||||
*/
|
||||
class PureLogicTest extends TestCase
|
||||
{
|
||||
private function getTfeController(): TfeController
|
||||
{
|
||||
// We need a TfeController instance to test protected methods.
|
||||
// Use the anonymous subclass pattern.
|
||||
$db = TestDatabase::getInstance();
|
||||
return new class($db) extends TfeController {
|
||||
public function exposedSplitJuryByRole(array $jury): array { return $this->splitJuryByRole($jury); }
|
||||
public function exposedCollectCaptionPaths(array $files): array { return $this->collectCaptionPaths($files); }
|
||||
};
|
||||
}
|
||||
|
||||
private function getThesisCreateController(): ThesisCreateController
|
||||
{
|
||||
$db = TestDatabase::getInstance();
|
||||
return new class($db) extends ThesisCreateController {
|
||||
public function exposedDetectFileType(string $mimeType, string $ext): string { return $this->detectFileType($mimeType, $ext); }
|
||||
};
|
||||
}
|
||||
|
||||
// ── splitJuryByRole ──────────────────────────────────────────────────────
|
||||
|
||||
public function testSplitJuryByRoleAllRoles(): void
|
||||
{
|
||||
$ctrl = $this->getTfeController();
|
||||
$jury = [
|
||||
['name' => 'Alice', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0],
|
||||
['name' => 'Bob', 'role' => 'promoteur', 'is_external' => 0, 'is_ulb' => 0],
|
||||
['name' => 'Carol', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 1],
|
||||
['name' => 'Dave', 'role' => 'promoteur', 'is_external' => 1, 'is_ulb' => 0],
|
||||
['name' => 'Eve', 'role' => 'lecteur', 'is_external' => 0, 'is_ulb' => 0],
|
||||
['name' => 'Frank', 'role' => 'lecteur', 'is_external' => 1, 'is_ulb' => 0],
|
||||
];
|
||||
$split = $ctrl->exposedSplitJuryByRole($jury);
|
||||
|
||||
$this->assertSame(['Alice'], $split['presidents']);
|
||||
$this->assertSame(['Bob'], $split['internes']);
|
||||
$this->assertSame(['Carol'], $split['ulb']);
|
||||
$this->assertSame(['Dave'], $split['externes']);
|
||||
$this->assertSame(['Eve'], $split['lecteurs_internes']);
|
||||
$this->assertSame(['Frank'], $split['lecteurs_externes']);
|
||||
}
|
||||
|
||||
public function testSplitJuryByRoleEmptyNameSkipped(): void
|
||||
{
|
||||
$ctrl = $this->getTfeController();
|
||||
$jury = [['name' => '', 'role' => 'president', 'is_external' => 0, 'is_ulb' => 0]];
|
||||
$split = $ctrl->exposedSplitJuryByRole($jury);
|
||||
|
||||
$this->assertEmpty($split['presidents']);
|
||||
}
|
||||
|
||||
public function testSplitJuryByRoleEmptyJury(): void
|
||||
{
|
||||
$ctrl = $this->getTfeController();
|
||||
$split = $ctrl->exposedSplitJuryByRole([]);
|
||||
|
||||
$this->assertEmpty($split['presidents']);
|
||||
$this->assertEmpty($split['internes']);
|
||||
$this->assertEmpty($split['ulb']);
|
||||
$this->assertEmpty($split['externes']);
|
||||
$this->assertEmpty($split['lecteurs_internes']);
|
||||
$this->assertEmpty($split['lecteurs_externes']);
|
||||
}
|
||||
|
||||
// ── collectCaptionPaths ──────────────────────────────────────────────────
|
||||
|
||||
public function testCollectCaptionPathsVttByMime(): void
|
||||
{
|
||||
$ctrl = $this->getTfeController();
|
||||
$files = [
|
||||
['mime_type' => 'application/pdf', 'file_path' => 'main.pdf'],
|
||||
['mime_type' => 'text/vtt', 'file_path' => 'captions1.vtt'],
|
||||
['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'],
|
||||
['mime_type' => 'text/plain', 'file_path' => 'captions2.vtt'],
|
||||
];
|
||||
$captions = $ctrl->exposedCollectCaptionPaths($files);
|
||||
|
||||
// Only the VTT files, in order
|
||||
$this->assertCount(2, $captions);
|
||||
$this->assertSame('captions1.vtt', $captions[0]);
|
||||
$this->assertSame('captions2.vtt', $captions[1]);
|
||||
}
|
||||
|
||||
public function testCollectCaptionPathsVttByExtension(): void
|
||||
{
|
||||
$ctrl = $this->getTfeController();
|
||||
$files = [
|
||||
['mime_type' => 'application/octet-stream', 'file_path' => 'sub.vtt'],
|
||||
['mime_type' => 'video/mp4', 'file_path' => 'video.mp4'],
|
||||
];
|
||||
$captions = $ctrl->exposedCollectCaptionPaths($files);
|
||||
|
||||
$this->assertCount(1, $captions);
|
||||
$this->assertSame('sub.vtt', $captions[0]);
|
||||
}
|
||||
|
||||
public function testCollectCaptionPathsNoVttReturnsEmpty(): void
|
||||
{
|
||||
$ctrl = $this->getTfeController();
|
||||
$files = [['mime_type' => 'video/mp4', 'file_path' => 'video.mp4']];
|
||||
|
||||
$this->assertEmpty($ctrl->exposedCollectCaptionPaths($files));
|
||||
}
|
||||
|
||||
// ── detectFileType ───────────────────────────────────────────────────────
|
||||
|
||||
public function testDetectFileTypeByMime(): void
|
||||
{
|
||||
$ctrl = $this->getThesisCreateController();
|
||||
|
||||
$this->assertSame('caption', $ctrl->exposedDetectFileType('text/vtt', 'vtt'));
|
||||
$this->assertSame('audio', $ctrl->exposedDetectFileType('audio/mpeg', 'mp3'));
|
||||
$this->assertSame('audio', $ctrl->exposedDetectFileType('audio/ogg', 'ogg'));
|
||||
$this->assertSame('video', $ctrl->exposedDetectFileType('video/mp4', 'mp4'));
|
||||
$this->assertSame('video', $ctrl->exposedDetectFileType('video/webm', 'webm'));
|
||||
$this->assertSame('main', $ctrl->exposedDetectFileType('application/pdf', 'pdf'));
|
||||
$this->assertSame('image', $ctrl->exposedDetectFileType('image/jpeg', 'jpg'));
|
||||
$this->assertSame('image', $ctrl->exposedDetectFileType('image/png', 'png'));
|
||||
$this->assertSame('other', $ctrl->exposedDetectFileType('application/zip', 'zip'));
|
||||
}
|
||||
|
||||
public function testDetectFileTypeByExtensionFallback(): void
|
||||
{
|
||||
$ctrl = $this->getThesisCreateController();
|
||||
|
||||
$this->assertSame('audio', $ctrl->exposedDetectFileType('application/octet-stream', 'mp3'));
|
||||
$this->assertSame('video', $ctrl->exposedDetectFileType('application/octet-stream', 'mp4'));
|
||||
$this->assertSame('main', $ctrl->exposedDetectFileType('application/octet-stream', 'pdf'));
|
||||
$this->assertSame('image', $ctrl->exposedDetectFileType('application/octet-stream', 'webp'));
|
||||
$this->assertSame('caption', $ctrl->exposedDetectFileType('application/octet-stream', 'vtt'));
|
||||
}
|
||||
}
|
||||
52
tests/phpunit/SearchControllerTest.php
Normal file
52
tests/phpunit/SearchControllerTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* SearchControllerTest — Regression test for SearchController::handleSearch()
|
||||
* always returning a 'coverMap' key.
|
||||
*/
|
||||
class SearchControllerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
TestDatabase::resetData();
|
||||
TestDatabase::seedBasicThesis('Test Thesis', 'Author', 2024);
|
||||
|
||||
// Set up GET for SearchController (it reads from $_GET)
|
||||
$_GET = ['query' => ''];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_GET = [];
|
||||
}
|
||||
|
||||
public function testHandleSearchReturnsCoverMapKey(): void
|
||||
{
|
||||
$db = TestDatabase::getInstance();
|
||||
$rateLimit = new RateLimit(1000, 60, sys_get_temp_dir() . '/xamxam_rl_test_' . uniqid());
|
||||
$searchCtrl = new SearchController($db, $rateLimit);
|
||||
|
||||
$vars = $searchCtrl->handleSearch();
|
||||
|
||||
$this->assertArrayHasKey('coverMap', $vars);
|
||||
$this->assertIsArray($vars['coverMap']);
|
||||
}
|
||||
|
||||
public function testCoverMapContainsKnownThesis(): void
|
||||
{
|
||||
$pdo = TestDatabase::getPDO();
|
||||
$thesisId = $pdo->query('SELECT id FROM theses LIMIT 1')->fetchColumn();
|
||||
|
||||
$db = TestDatabase::getInstance();
|
||||
$rateLimit = new RateLimit(1000, 60, sys_get_temp_dir() . '/xamxam_rl_test2_' . uniqid());
|
||||
$searchCtrl = new SearchController($db, $rateLimit);
|
||||
|
||||
$vars = $searchCtrl->handleSearch();
|
||||
|
||||
if (!empty($vars['results'])) {
|
||||
$this->assertArrayHasKey((int)$thesisId, $vars['coverMap']);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user