feat: extract MediaController, wire into Dispatcher, delete media.php

This commit is contained in:
Pontoporeia
2026-04-17 11:44:08 +02:00
parent b03be51b92
commit 75f808bee4
157 changed files with 1713 additions and 452 deletions

View File

@@ -0,0 +1,84 @@
<?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;
}

234
app/tests/README.md Normal file
View File

@@ -0,0 +1,234 @@
# Post-ERG Test Suite
Centralized test suite for the Post-ERG 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 test database at `database/test.db`.
### Setup Test Database
```bash
# Create from schema
just init-db
# Create with fixtures (sample data)
just fixtures
```
### Reset Test Database
```bash
just reset-db
```
## 📊 Expected Output
Successful test run:
```
╔════════════════════════════════════════════╗
║ Post-ERG 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`

View File

@@ -0,0 +1,74 @@
<?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;
}

View File

@@ -0,0 +1,83 @@
<?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;
}

View File

@@ -0,0 +1,54 @@
<?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;
}

91
app/tests/run-tests.php Executable file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env php
<?php
/**
* Post-ERG Test Runner
* Runs all tests in the tests/ directory
*/
// Tests always run against the test database; require an explicit opt-in so
// that a stray test.db on disk never silently redirects a production session.
putenv('DB_ENV=test');
echo "╔════════════════════════════════════════════╗\n";
echo "║ Post-ERG 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' => '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);
}