refactor: extract buildSearchConditions, add getThesesList, remove dead code, fix SearchTest

- Database: extract private buildSearchConditions(array $params): array shared by
  searchTheses() and countSearchResults(), eliminating ~80 lines of duplication;
  add array type hints to both public methods
- Database: add getThesesList(array $filters) and getAllYears() so admin/index.php
  no longer builds raw SQL inline
- admin/index.php: replace inline PDO query block with $db->getThesesList() /
  $db->getAllYears(); drop the now-unused $pdo local
- config/bootstrap.php: remove dead include_template() helper and the
  vendor/autoload.php Composer stub (no vendor/ directory exists)
- apps/: delete entire directory (leftover artefact, no code references it)
- tests/Integration/SearchTest.php: fix three searchTheses() calls from bare
  strings to proper array params to match the method signature (prevented TypeError)
This commit is contained in:
Pontoporeia
2026-02-24 23:21:44 +01:00
parent d30153871f
commit eaad740574
8 changed files with 102 additions and 375 deletions

14
TODO.md
View File

@@ -39,7 +39,7 @@ third-party dependencies. The tasks below are ordered from critical to nice-to-h
`src/Database.php` and `src/AdminAuth.php` via `APP_ROOT` (the constant already `src/Database.php` and `src/AdminAuth.php` via `APP_ROOT` (the constant already
defined in `bootstrap.php`), removing the fragile relative-path `../../` chains. defined in `bootstrap.php`), removing the fragile relative-path `../../` chains.
- [ ] **Eliminate the duplicate `searchTheses` / `countSearchResults` condition block** - [x] **Eliminate the duplicate `searchTheses` / `countSearchResults` condition block**
`Database::searchTheses()` and `Database::countSearchResults()` share identical `Database::searchTheses()` and `Database::countSearchResults()` share identical
WHERE-clause construction logic (~80 lines each). Extract a private WHERE-clause construction logic (~80 lines each). Extract a private
`buildSearchConditions(array $params): array` helper that returns `[$conditions, `buildSearchConditions(array $params): array` helper that returns `[$conditions,
@@ -52,7 +52,7 @@ third-party dependencies. The tasks below are ordered from critical to nice-to-h
instead promoting the most-used raw queries into `Database` methods, reducing instead promoting the most-used raw queries into `Database` methods, reducing
direct PDO exposure. direct PDO exposure.
- [ ] **Move inline SQL in `admin/index.php` into `Database`** - [x] **Move inline SQL in `admin/index.php` into `Database`**
`admin/index.php` builds a raw SQL query with dynamic filter conditions directly in `admin/index.php` builds a raw SQL query with dynamic filter conditions directly in
the page. This is the only admin page doing so. Add a `getThesesList(array the page. This is the only admin page doing so. Add a `getThesesList(array
$filters): array` method to `Database` to match the pattern used everywhere else. $filters): array` method to `Database` to match the pattern used everywhere else.
@@ -67,20 +67,20 @@ third-party dependencies. The tasks below are ordered from critical to nice-to-h
## What Can Be Removed / Simplified ## What Can Be Removed / Simplified
- [ ] **Remove `include_template()` helper from `bootstrap.php` — it is never called** - [x] **Remove `include_template()` helper from `bootstrap.php` — it is never called**
The function `include_template($name)` in `config/bootstrap.php` is dead code; The function `include_template($name)` in `config/bootstrap.php` is dead code;
pages use direct `include APP_ROOT . '/templates/...'` instead. pages use direct `include APP_ROOT . '/templates/...'` instead.
- [ ] **Remove the Composer autoload stub from `bootstrap.php`** - [x] **Remove the Composer autoload stub from `bootstrap.php`**
`bootstrap.php` has `if (file_exists(APP_ROOT . '/vendor/autoload.php'))` — there `bootstrap.php` has `if (file_exists(APP_ROOT . '/vendor/autoload.php'))` — there
is no Composer vendor directory and no plan for one. Remove this dead branch. is no Composer vendor directory and no plan for one. Remove this dead branch.
- [ ] **Delete `apps/admin/` directory** - [x] **Delete `apps/admin/` directory**
`apps/admin/` contains only `data/` (empty with test data) and `error.log` and `apps/admin/` contains only `data/` (empty with test data) and `error.log` and
`test.db`. It appears to be a leftover from an earlier structure. If confirmed `test.db`. It appears to be a leftover from an earlier structure. If confirmed
unused, delete it. unused, delete it.
- [ ] **Remove `apps/` directory entirely if it contains only residual artefacts** - [x] **Remove `apps/` directory entirely if it contains only residual artefacts**
Related to the above — verify no active code references `apps/`. Related to the above — verify no active code references `apps/`.
--- ---
@@ -102,7 +102,7 @@ third-party dependencies. The tasks below are ordered from critical to nice-to-h
## Testing Infrastructure ## Testing Infrastructure
- [ ] **Fix `SearchTest.php` — it calls `searchTheses()` with a string, not an array** - [x] **Fix `SearchTest.php` — it calls `searchTheses()` with a string, not an array**
`$db->searchTheses('art')` passes a string, but `searchTheses()` expects `$db->searchTheses('art')` passes a string, but `searchTheses()` expects
`array $params`. This test would throw a TypeError at runtime. Fix the call to `array $params`. This test would throw a TypeError at runtime. Fix the call to
`$db->searchTheses(['query' => 'art'])`. `$db->searchTheses(['query' => 'art'])`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

View File

@@ -1,251 +0,0 @@
[27-Jan-2026 14:57:08 UTC] FILES array: Array
(
[couverture] => Array
(
[name] =>
[full_path] =>
[type] =>
[tmp_name] =>
[error] => 4
[size] => 0
)
[files] => Array
(
[name] => Array
(
[0] =>
)
[full_path] => Array
(
[0] =>
)
[type] => Array
(
[0] =>
)
[tmp_name] => Array
(
[0] =>
)
[error] => Array
(
[0] => 4
)
[size] => Array
(
[0] => 0
)
)
)
[27-Jan-2026 14:57:08 UTC] Form processing error: Veuillez sélectionner au moins une langue.
[27-Jan-2026 15:16:43 UTC] FILES array: Array
(
[couverture] => Array
(
[name] =>
[full_path] =>
[type] =>
[tmp_name] =>
[error] => 4
[size] => 0
)
[files] => Array
(
[name] => Array
(
[0] =>
)
[full_path] => Array
(
[0] =>
)
[type] => Array
(
[0] =>
)
[tmp_name] => Array
(
[0] =>
)
[error] => Array
(
[0] => 4
)
[size] => Array
(
[0] => 0
)
)
)
[27-Jan-2026 15:16:43 UTC] Form processing error: Lien URL invalide.
[27-Jan-2026 15:30:28 UTC] FILES array: Array
(
[couverture] => Array
(
[name] =>
[full_path] =>
[type] =>
[tmp_name] =>
[error] => 4
[size] => 0
)
[files] => Array
(
[name] => Array
(
[0] =>
)
[full_path] => Array
(
[0] =>
)
[type] => Array
(
[0] =>
)
[tmp_name] => Array
(
[0] =>
)
[error] => Array
(
[0] => 4
)
[size] => Array
(
[0] => 0
)
)
)
[27-Jan-2026 15:30:28 UTC] Author ID: 1
[27-Jan-2026 15:30:28 UTC] Thesis ID: 1
[27-Jan-2026 15:30:29 UTC] Thesis submission completed successfully: 2026-001
[27-Jan-2026 15:33:11 UTC] FILES array: Array
(
[couverture] => Array
(
[name] =>
[full_path] =>
[type] =>
[tmp_name] =>
[error] => 4
[size] => 0
)
[files] => Array
(
[name] => Array
(
[0] =>
)
[full_path] => Array
(
[0] =>
)
[type] => Array
(
[0] =>
)
[tmp_name] => Array
(
[0] =>
)
[error] => Array
(
[0] => 4
)
[size] => Array
(
[0] => 0
)
)
)
[27-Jan-2026 15:33:11 UTC] Author ID: 2
[27-Jan-2026 15:33:11 UTC] Thesis ID: 2
[27-Jan-2026 15:33:12 UTC] Thesis submission completed successfully: 2026-002
[27-Jan-2026 15:48:51 UTC] FILES array: Array
(
[couverture] => Array
(
[name] =>
[full_path] =>
[type] =>
[tmp_name] =>
[error] => 4
[size] => 0
)
[files] => Array
(
[name] => Array
(
[0] =>
)
[full_path] => Array
(
[0] =>
)
[type] => Array
(
[0] =>
)
[tmp_name] => Array
(
[0] =>
)
[error] => Array
(
[0] => 4
)
[size] => Array
(
[0] => 0
)
)
)
[27-Jan-2026 15:48:51 UTC] Author ID: 14
[27-Jan-2026 15:48:51 UTC] Thesis ID: 14
[27-Jan-2026 15:48:51 UTC] Thesis submission completed successfully: 2026-003

Binary file not shown.

View File

@@ -24,21 +24,7 @@ if (php_sapi_name() === 'cli-server') {
ini_set('log_errors', '1'); ini_set('log_errors', '1');
} }
// Simple helper function for including templates
function include_template($name)
{
$path = APP_ROOT . '/templates/' . $name;
if (file_exists($path)) {
include $path;
}
}
// Load admin credentials if available (defines ADMIN_PASSWORD_HASH for AdminAuth) // Load admin credentials if available (defines ADMIN_PASSWORD_HASH for AdminAuth)
if (file_exists(APP_ROOT . '/config/admin_credentials.php')) { if (file_exists(APP_ROOT . '/config/admin_credentials.php')) {
require_once APP_ROOT . '/config/admin_credentials.php'; require_once APP_ROOT . '/config/admin_credentials.php';
} }
// Autoload Composer dependencies if available
if (file_exists(APP_ROOT . '/vendor/autoload.php')) {
require_once APP_ROOT . '/vendor/autoload.php';
}

View File

@@ -17,59 +17,25 @@ require_once __DIR__ . '/../../src/Database.php';
try { try {
$db = new Database(); $db = new Database();
$pdo = $db->getPDO();
// Get filter parameters // Get filter parameters
$searchQuery = isset($_GET['search']) ? trim($_GET['search']) : ''; $searchQuery = isset($_GET['search']) ? trim($_GET['search']) : '';
$yearFilter = isset($_GET['year']) ? intval($_GET['year']) : null; $yearFilter = isset($_GET['year']) ? intval($_GET['year']) : null;
$orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null; $orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null;
// Build query $filters = [];
$sql = "SELECT
t.id, t.identifier, t.title, t.subtitle, t.year,
o.name as orientation,
ap.name as ap_program,
GROUP_CONCAT(DISTINCT a.name) as authors,
t.submitted_at,
t.is_published
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id
WHERE 1=1";
$params = [];
if ($searchQuery) { if ($searchQuery) {
$sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)"; $filters['search'] = $searchQuery;
$searchParam = "%$searchQuery%";
$params[] = $searchParam;
$params[] = $searchParam;
$params[] = $searchParam;
} }
if ($yearFilter) { if ($yearFilter) {
$sql .= " AND t.year = ?"; $filters['year'] = $yearFilter;
$params[] = $yearFilter;
} }
if ($orientationFilter) { if ($orientationFilter) {
$sql .= " AND t.orientation_id = ?"; $filters['orientation'] = $orientationFilter;
$params[] = $orientationFilter;
} }
$sql .= " GROUP BY t.id ORDER BY t.year DESC, t.submitted_at DESC"; $theses = $db->getThesesList($filters);
$years = $db->getAllYears();
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$theses = $stmt->fetchAll();
// Get unique years for filter
$yearsStmt = $pdo->query("SELECT DISTINCT year FROM theses ORDER BY year DESC");
$years = $yearsStmt->fetchAll(PDO::FETCH_COLUMN);
// Get orientations for filter
$orientations = $db->getAllOrientations(); $orientations = $db->getAllOrientations();
} catch (Exception $e) { } catch (Exception $e) {
error_log("Error loading theses list: " . $e->getMessage()); error_log("Error loading theses list: " . $e->getMessage());

View File

@@ -257,13 +257,13 @@ class Database {
} }
/** /**
* Search theses with filters (secure implementation) * Build WHERE conditions and named bindings from validated search params.
* Always includes the `is_published = 1` guard.
*
* @param array $params Already-validated params (output of validateSearchParams)
* @return array{0: string[], 1: array<string,mixed>} [$conditions, $bindings]
*/ */
public function searchTheses($params = [], $limit = 20, $offset = 0) { private function buildSearchConditions(array $params): array {
$params = $this->validateSearchParams($params);
$limit = max(1, min(100, intval($limit)));
$offset = max(0, intval($offset));
$conditions = ["is_published = 1"]; $conditions = ["is_published = 1"];
$bindings = []; $bindings = [];
@@ -319,6 +319,19 @@ class Database {
$bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0; $bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0;
} }
return [$conditions, $bindings];
}
/**
* Search theses with filters (secure implementation)
*/
public function searchTheses(array $params = [], $limit = 20, $offset = 0) {
$params = $this->validateSearchParams($params);
$limit = max(1, min(100, intval($limit)));
$offset = max(0, intval($offset));
[$conditions, $bindings] = $this->buildSearchConditions($params);
$whereClause = implode(' AND ', $conditions); $whereClause = implode(' AND ', $conditions);
$sql = "SELECT * FROM v_theses_public WHERE $whereClause ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset"; $sql = "SELECT * FROM v_theses_public WHERE $whereClause ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset";
@@ -336,62 +349,10 @@ class Database {
/** /**
* Count search results * Count search results
*/ */
public function countSearchResults($params = []) { public function countSearchResults(array $params = []) {
$params = $this->validateSearchParams($params); $params = $this->validateSearchParams($params);
$conditions = ["is_published = 1"];
$bindings = [];
if (!empty($params['query'])) { [$conditions, $bindings] = $this->buildSearchConditions($params);
$conditions[] = "(
title LIKE :query ESCAPE '\\' OR
subtitle LIKE :query ESCAPE '\\' OR
synopsis LIKE :query ESCAPE '\\' OR
authors LIKE :query ESCAPE '\\' OR
supervisors LIKE :query ESCAPE '\\' OR
keywords LIKE :query ESCAPE '\\'
)";
$bindings[':query'] = '%' . $params['query'] . '%';
}
if (!empty($params['year'])) {
$conditions[] = "year = :year";
$bindings[':year'] = $params['year'];
}
if (!empty($params['orientation'])) {
$conditions[] = "orientation LIKE :orientation ESCAPE '\\'";
$bindings[':orientation'] = '%' . $params['orientation'] . '%';
}
if (!empty($params['ap_program'])) {
$conditions[] = "ap_program LIKE :ap_program ESCAPE '\\'";
$bindings[':ap_program'] = '%' . $params['ap_program'] . '%';
}
if (!empty($params['finality'])) {
$conditions[] = "finality_type LIKE :finality ESCAPE '\\'";
$bindings[':finality'] = '%' . $params['finality'] . '%';
}
if (!empty($params['keyword'])) {
$conditions[] = "keywords LIKE :keyword ESCAPE '\\'";
$bindings[':keyword'] = '%' . $params['keyword'] . '%';
}
if (!empty($params['format'])) {
$conditions[] = "formats LIKE :format ESCAPE '\\'";
$bindings[':format'] = '%' . $params['format'] . '%';
}
if (!empty($params['language'])) {
$conditions[] = "languages LIKE :language ESCAPE '\\'";
$bindings[':language'] = '%' . $params['language'] . '%';
}
if (isset($params['is_doctoral'])) {
$conditions[] = "is_doctoral = :is_doctoral";
$bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0;
}
$whereClause = implode(' AND ', $conditions); $whereClause = implode(' AND ', $conditions);
$sql = "SELECT COUNT(*) as count FROM v_theses_public WHERE $whereClause"; $sql = "SELECT COUNT(*) as count FROM v_theses_public WHERE $whereClause";
@@ -508,6 +469,71 @@ class Database {
return $this->getLanguages(); return $this->getLanguages();
} }
// ========================================================================
// ADMIN LIST METHOD
// ========================================================================
/**
* Return theses for the admin list view, with optional filters.
*
* Filters (all optional):
* 'search' string matches title, subtitle, or author name (LIKE)
* 'year' int exact year match
* 'orientation' int orientation_id exact match
*
* @param array $filters
* @return array
*/
public function getThesesList(array $filters = []): array {
$sql = "SELECT
t.id, t.identifier, t.title, t.subtitle, t.year,
o.name as orientation,
ap.name as ap_program,
GROUP_CONCAT(DISTINCT a.name) as authors,
t.submitted_at,
t.is_published
FROM theses t
LEFT JOIN orientations o ON t.orientation_id = o.id
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id
LEFT JOIN authors a ON ta.author_id = a.id
WHERE 1=1";
$params = [];
if (!empty($filters['search'])) {
$sql .= " AND (t.title LIKE ? OR t.subtitle LIKE ? OR a.name LIKE ?)";
$searchParam = '%' . $filters['search'] . '%';
$params[] = $searchParam;
$params[] = $searchParam;
$params[] = $searchParam;
}
if (!empty($filters['year'])) {
$sql .= " AND t.year = ?";
$params[] = intval($filters['year']);
}
if (!empty($filters['orientation'])) {
$sql .= " AND t.orientation_id = ?";
$params[] = intval($filters['orientation']);
}
$sql .= " GROUP BY t.id ORDER BY t.year DESC, t.submitted_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Get distinct years present in the theses table (admin, includes unpublished).
*/
public function getAllYears(): array {
$stmt = $this->pdo->query("SELECT DISTINCT year FROM theses ORDER BY year DESC");
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
// ======================================================================== // ========================================================================
// CRUD METHODS (from formulaire) // CRUD METHODS (from formulaire)
// ======================================================================== // ========================================================================

View File

@@ -14,7 +14,7 @@ try {
// Test 1: Search with empty query // Test 1: Search with empty query
echo "Test 1: Empty Search Query\n"; echo "Test 1: Empty Search Query\n";
$results = $db->searchTheses(''); $results = $db->searchTheses([]);
if (is_array($results)) { if (is_array($results)) {
echo "✓ PASS: Empty query handled (returned " . count($results) . " results)\n\n"; echo "✓ PASS: Empty query handled (returned " . count($results) . " results)\n\n";
} else { } else {
@@ -24,7 +24,7 @@ try {
// Test 2: Search for specific term // Test 2: Search for specific term
echo "Test 2: Search for Specific Term\n"; echo "Test 2: Search for Specific Term\n";
$searchTerm = 'art'; // Common word likely to appear $searchTerm = 'art'; // Common word likely to appear
$results = $db->searchTheses($searchTerm); $results = $db->searchTheses(['query' => $searchTerm]);
if (is_array($results)) { if (is_array($results)) {
echo "✓ PASS: Search for '$searchTerm' returned " . count($results) . " results\n\n"; echo "✓ PASS: Search for '$searchTerm' returned " . count($results) . " results\n\n";
} else { } else {
@@ -33,7 +33,7 @@ try {
// Test 3: Search with special characters // Test 3: Search with special characters
echo "Test 3: Search with Special Characters\n"; echo "Test 3: Search with Special Characters\n";
$results = $db->searchTheses("test's \"quotes\" & symbols"); $results = $db->searchTheses(['query' => "test's \"quotes\" & symbols"]);
if (is_array($results)) { if (is_array($results)) {
echo "✓ PASS: Special characters handled safely\n\n"; echo "✓ PASS: Special characters handled safely\n\n";
} else { } else {