simplify-dependency-injection (#44)

Closes https://gitea.subcultureofone.org/greg/tkr/issues/43

Use a global $app dictionary to manage global state rather than having complex class constructors that expect three input arguments. Update and fix tests. Add tests for Util class functions that broke in the refactor.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/44
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
Greg Sarjeant 2025-08-02 19:52:33 +00:00 committed by greg
parent 9593a43cc0
commit 0b0fd29913
20 changed files with 389 additions and 216 deletions

View File

@ -41,21 +41,14 @@ if (!(preg_match('/setup$/', $path))) {
}
}
// Get a database connection
// TODO: Change from static function.
global $db;
// Initialize application context with all dependencies
global $app;
$db = Database::get();
// Initialize core entities
// Defining these as globals isn't great practice,
// but this is a small, single-user app and this data will rarely change.
global $config;
global $user;
$config = new ConfigModel($db);
$config = $config->loadFromDatabase();
$user = new UserModel($db);
$user = $user->loadFromDatabase();
$app = [
'db' => $db,
'config' => (new ConfigModel($db))->loadFromDatabase(),
'user' => (new UserModel($db))->loadFromDatabase(),
];
// Start a session and generate a CSRF Token
// if there isn't already an active session
@ -63,8 +56,8 @@ Session::start();
Session::generateCsrfToken();
// Remove the base path from the URL
if (strpos($path, $config->basePath) === 0) {
$path = substr($path, strlen($config->basePath));
if (strpos($path, $app['config']->basePath) === 0) {
$path = substr($path, strlen($app['config']->basePath));
}
// strip the trailing slash from the resulting route
@ -99,7 +92,7 @@ if ($method === 'POST' && $path != 'setup') {
header('Content-Type: text/html; charset=utf-8');
// Render the requested route or throw a 404
$router = new Router($db, $config, $user);
$router = new Router();
if (!$router->route($path, $method)){
http_response_code(404);
echo "404 - Page Not Found";

View File

@ -13,22 +13,26 @@ class AdminController extends Controller {
}
public function getAdminData(bool $isSetup): array {
global $app;
Log::debug("Loading admin page" . ($isSetup ? " (setup mode)" : ""));
return [
'user' => $this->user,
'config' => $this->config,
'user' => $app['user'],
'config' => $app['config'],
'isSetup' => $isSetup,
];
}
public function handleSave(){
global $app;
if (!Session::isLoggedIn()){
header('Location: ' . Util::buildRelativeUrl($this->config->basePath, 'login'));
header('Location: ' . Util::buildRelativeUrl($app['config']->basePath, 'login'));
exit;
}
$result = $this->processSettingsSave($_POST, false);
$result = $this->saveSettings($_POST, false);
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
@ -36,12 +40,14 @@ class AdminController extends Controller {
public function handleSetup(){
// for setup, we don't care if they're logged in
// (because they can't be until setup is complete)
$result = $this->processSettingsSave($_POST, true);
$result = $this->saveSettings($_POST, true);
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
public function processSettingsSave(array $postData, bool $isSetup): array {
public function saveSettings(array $postData, bool $isSetup): array {
global $app;
$result = ['success' => false, 'errors' => []];
Log::debug("Processing settings save" . ($isSetup ? " (setup mode)" : ""));
@ -122,30 +128,30 @@ class AdminController extends Controller {
if (empty($errors)) {
try {
// Update site settings
$this->config->siteTitle = $siteTitle;
$this->config->siteDescription = $siteDescription;
$this->config->baseUrl = $baseUrl;
$this->config->basePath = $basePath;
$this->config->itemsPerPage = $itemsPerPage;
$this->config->strictAccessibility = $strictAccessibility;
$this->config->logLevel = $logLevel;
$app['config']->siteTitle = $siteTitle;
$app['config']->siteDescription = $siteDescription;
$app['config']->baseUrl = $baseUrl;
$app['config']->basePath = $basePath;
$app['config']->itemsPerPage = $itemsPerPage;
$app['config']->strictAccessibility = $strictAccessibility;
$app['config']->logLevel = $logLevel;
// Save site settings and reload config from database
$this->config = $this->config->save();
$app['config'] = $app['config']->save();
Log::info("Site settings updated");
// Update user profile
$this->user->username = $username;
$this->user->displayName = $displayName;
$this->user->website = $website;
$app['user']->username = $username;
$app['user']->displayName = $displayName;
$app['user']->website = $website;
// Save user profile and reload user from database
$this->user = $this->user->save();
$app['user'] = $app['user']->save();
Log::info("User profile updated");
// Update the password if one was sent
if($password){
$this->user->setPassword($password);
$app['user']->setPassword($password);
Log::info("User password updated");
}

View File

@ -1,11 +1,12 @@
<?php
class AuthController extends Controller {
function showLogin(?string $error = null){
global $config;
global $app;
$csrf_token = Session::getCsrfToken();
$vars = [
'config' => $config,
'config' => $app['config'],
'csrf_token' => $csrf_token,
'error' => $error,
];
@ -14,7 +15,7 @@ class AuthController extends Controller {
}
function handleLogin(){
global $config;
global $app;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
@ -22,7 +23,7 @@ class AuthController extends Controller {
Log::debug("Login attempt for user {$username}");
$userModel = new UserModel();
$userModel = new UserModel($app['db']);
$user = $userModel->getByUsername($username);
//if ($user && password_verify($password, $user['password_hash'])) {
@ -30,7 +31,7 @@ class AuthController extends Controller {
Log::info("Successful login for {$username}");
Session::newLoginSession($user);
header('Location: ' . Util::buildRelativeUrl($config->basePath));
header('Location: ' . Util::buildRelativeUrl($app['config']->basePath));
exit;
} else {
Log::warning("Failed login for {$username}");
@ -44,11 +45,12 @@ class AuthController extends Controller {
}
function handleLogout(){
global $app;
Log::info("Logout from user " . $_SESSION['username']);
Session::end();
global $config;
header('Location: ' . Util::buildRelativeUrl($config->basePath));
header('Location: ' . Util::buildRelativeUrl($app['config']->basePath));
exit;
}
}

View File

@ -1,7 +1,5 @@
<?php
class Controller {
public function __construct(protected PDO $db, protected ConfigModel $config, protected UserModel $user) {}
// Renders the requested template inside templates/main/php
protected function render(string $childTemplateFile, array $vars = []) {
$templatePath = TEMPLATES_DIR . "/main.php";

View File

@ -2,13 +2,13 @@
class CssController extends Controller {
public function index() {
global $config;
global $user;
global $app;
$customCss = CssModel::load();
$vars = [
'user' => $user,
'config' => $config,
'user' => $app['user'],
'config' => $app['config'],
'customCss' => $customCss,
];
@ -49,8 +49,6 @@ class CssController extends Controller {
}
public function handlePost() {
global $config;
switch ($_POST['action']) {
case 'upload':
$this->handleUpload();
@ -69,7 +67,7 @@ class CssController extends Controller {
}
public function handleDelete(): void{
global $config;
global $app;
// Don't try to delete the default theme.
if (!$_POST['selectCssFile']){
@ -113,26 +111,26 @@ class CssController extends Controller {
}
// Set the theme back to default
$config->cssId = null;
$config = $config->save();
$app['config']->cssId = null;
$app['config'] = $app['config']->save();
// Set flash message
Session::setFlashMessage('success', 'Theme ' . $cssFilename . ' deleted.');
}
private function handleSetTheme() {
global $config;
global $app;
if ($_POST['selectCssFile']){
// Set custom theme
$config->cssId = $_POST['selectCssFile'];
$app['config']->cssId = $_POST['selectCssFile'];
} else {
// Set default theme
$config->cssId = null;
$app['config']->cssId = null;
}
// Update the site theme
$config = $config->save();
$app['config'] = $app['config']->save();
// Set flash message
Session::setFlashMessage('success', 'Theme applied.');

View File

@ -2,11 +2,12 @@
class EmojiController extends Controller {
// Shows the custom emoji management page
public function index(){
global $config;
global $app;
$emojiList = EmojiModel::loadAll();
$vars = [
'config' => $config,
'config' => $app['config'],
'emojiList' => $emojiList,
];
@ -14,7 +15,7 @@
}
public function handlePost(): void {
global $config;
global $app;
switch ($_POST['action']) {
case 'add':
@ -29,7 +30,7 @@
break;
}
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'admin/emoji'));
header('Location: ' . Util::buildRelativeUrl($app['config']->basePath, 'admin/emoji'));
exit;
}

View File

@ -2,17 +2,19 @@
class FeedController extends Controller {
private $ticks;
public function __construct(PDO $db, ConfigModel $config, UserModel $user){
parent::__construct($db, $config, $user);
public function __construct() {
global $app;
$tickModel = new TickModel($db, $config);
$this->ticks = $tickModel->getPage($config->itemsPerPage);
$tickModel = new TickModel($app['db'], $app['config']);
$this->ticks = $tickModel->getPage($app['config']->itemsPerPage);
Log::debug("Loaded " . count($this->ticks) . " ticks for feeds");
}
public function rss(){
$generator = new RssGenerator($this->config, $this->ticks);
global $app;
$generator = new RssGenerator($app['config'], $this->ticks);
Log::debug("Generating RSS feed with " . count($this->ticks) . " ticks");
header('Content-Type: ' . $generator->getContentType());
@ -20,7 +22,9 @@ class FeedController extends Controller {
}
public function atom(){
$generator = new AtomGenerator($this->config, $this->ticks);
global $app;
$generator = new AtomGenerator($app['config'], $this->ticks);
Log::debug("Generating Atom feed with " . count($this->ticks) . " ticks");
header('Content-Type: ' . $generator->getContentType());

View File

@ -9,21 +9,23 @@ class HomeController extends Controller {
}
public function getHomeData(int $page): array {
global $app;
Log::debug("Loading home page $page");
$tickModel = new TickModel($this->db, $this->config);
$limit = $this->config->itemsPerPage;
$tickModel = new TickModel($app['db'], $app['config']);
$limit = $app['config']->itemsPerPage;
$offset = ($page - 1) * $limit;
$ticks = $tickModel->getPage($limit, $offset);
$view = new TicksView($this->config, $ticks, $page);
$view = new TicksView($app['config'], $ticks, $page);
$tickList = $view->getHtml();
Log::info("Home page loaded with " . count($ticks) . " ticks");
return [
'config' => $this->config,
'user' => $this->user,
'config' => $app['config'],
'user' => $app['user'],
'tickList' => $tickList,
];
}
@ -31,14 +33,18 @@ class HomeController extends Controller {
// POST handler
// Saves the tick and reloads the homepage
public function handleTick(){
global $app;
$result = $this->processTick($_POST);
// redirect to the index (will show the latest tick if one was sent)
header('Location: ' . Util::buildRelativeUrl($this->config->basePath));
header('Location: ' . Util::buildRelativeUrl($app['config']->basePath));
exit;
}
public function processTick(array $postData): array {
global $app;
$result = ['success' => false, 'message' => ''];
if (!isset($postData['new_tick'])) {
@ -55,7 +61,7 @@ class HomeController extends Controller {
}
try {
$tickModel = new TickModel($this->db, $this->config);
$tickModel = new TickModel($app['db'], $app['config']);
$tickModel->insert($tickContent);
Log::info("New tick created: " . substr($tickContent, 0, 50) . (strlen($tickContent) > 50 ? '...' : ''));
$result['success'] = true;

View File

@ -2,16 +2,16 @@
class LogController extends Controller {
private string $storageDir;
public function __construct(PDO $db, ConfigModel $config, UserModel $user, ?string $storageDir = null) {
parent::__construct($db, $config, $user);
public function __construct(?string $storageDir = null) {
$this->storageDir = $storageDir ?? STORAGE_DIR;
}
public function index() {
global $app;
// Ensure user is logged in
if (!Session::isLoggedIn()) {
global $config;
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'login'));
header('Location: ' . Util::buildRelativeUrl($app['config']->basePath, 'login'));
exit;
}
@ -26,7 +26,7 @@ class LogController extends Controller {
}
public function getLogData(string $levelFilter = '', string $routeFilter = ''): array {
global $config;
global $app;
$limit = 300; // Show last 300 log entries
@ -38,7 +38,7 @@ class LogController extends Controller {
$availableLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
return [
'config' => $config,
'config' => $app['config'],
'logEntries' => $logEntries,
'availableRoutes' => $availableRoutes,
'availableLevels' => $availableLevels,

View File

@ -1,14 +1,14 @@
<?php
class MoodController extends Controller {
public function index(){
global $config;
global $user;
global $app;
$view = new MoodView();
$moodPicker = $view->render_mood_picker(self::getEmojisWithLabels(), $user->mood);
$moodPicker = $view->render_mood_picker(self::getEmojisWithLabels(), $app['user']->mood);
$vars = [
'config' => $config,
'config' => $app['config'],
'moodPicker' => $moodPicker,
];
@ -16,11 +16,9 @@
}
public function handlePost(){
global $app;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Get the data we need
global $config;
global $user;
switch ($_POST['action']){
case 'set':
$mood = $_POST['mood'];
@ -31,11 +29,11 @@
}
// set or clear the mood
$user->mood = $mood;
$user = $user->save();
$app['user']->mood = $mood;
$app['user'] = $app['user']->save();
// go back to the index and show the updated mood
header('Location: ' . Util::buildRelativeUrl($config->basePath));
header('Location: ' . Util::buildRelativeUrl($app['config']->basePath));
exit;
}
}

View File

@ -3,7 +3,9 @@
class TickController extends Controller{
//public function index(string $year, string $month, string $day, string $hour, string $minute, string $second){
public function index(int $id){
$tickModel = new TickModel();
global $app;
$tickModel = new TickModel($app['db'], $app['config']);
$vars = $tickModel->get($id);
$this->render('tick.php', $vars);
}

View File

@ -44,8 +44,8 @@ class Log {
}
private static function write($level, $message) {
global $config;
$logLevel = $config->logLevel ?? self::LEVELS['INFO'];
global $app;
$logLevel = $app['config']->logLevel ?? self::LEVELS['INFO'];
// Only log messages if they're at or above the configured log level.
if (self::LEVELS[$level] < $logLevel){

View File

@ -1,8 +1,6 @@
<?php
// Very simple router class
class Router {
public function __construct(private PDO $db, private ConfigModel $config, private UserModel $user) {}
// Define the recognized routes.
// Anything else will 404.
private static $routeHandlers = [
@ -61,7 +59,7 @@ class Router {
Log::debug("Handling request with Controller {$controllerName} and function {$functionName}");
$instance = new $controllerName($this->db, $this->config, $this->user);
$instance = new $controllerName();
call_user_func_array([$instance, $functionName], $matches);
return true;
}

View File

@ -25,12 +25,12 @@ class Util {
return preg_replace_callback(
'~(https?://[^\s<>"\'()]+)~i',
function($matches) use ($link_attrs) {
global $config;
global $app;
$escaped_url = rtrim($matches[1], '.,!?;:)]}>');
$clean_url = html_entity_decode($escaped_url, ENT_QUOTES, 'UTF-8');
$tabIndex = $config->strictAccessibility ? ' tabindex="0" ' : ' ';
$tabIndex = $app['config']->strictAccessibility ? ' tabindex="0"' : '';
return '<a' . $tabIndex . 'href="' . $clean_url . '"' . $link_attrs . '>' . $escaped_url . '</a>';
return '<a' . $tabIndex . ' href="' . $clean_url . '"' . $link_attrs . '>' . $escaped_url . '</a>';
},
$text
);
@ -66,41 +66,41 @@ class Util {
public static function buildUrl(string $baseUrl, string $basePath, string $path = ''): string {
// Normalize baseUrl (remove trailing slash)
$baseUrl = rtrim($baseUrl, '/');
// Normalize basePath (ensure leading slash, remove trailing slash unless it's just '/')
if ($basePath === '' || $basePath === '/') {
$basePath = '/';
} else {
$basePath = '/' . trim($basePath, '/') . '/';
}
// Normalize path (remove leading slash if present)
$path = ltrim($path, '/');
return $baseUrl . $basePath . $path;
}
public static function buildRelativeUrl(string $basePath, string $path = ''): string {
// Ensure basePath starts with / for relative URLs
$basePath = '/' . ltrim($basePath, '/');
// Remove trailing slash unless it's just '/'
if ($basePath !== '/') {
$basePath = rtrim($basePath, '/');
}
// Add path
$path = ltrim($path, '/');
if ($path === '') {
return $basePath;
}
// If basePath is root, don't add extra slash
if ($basePath === '/') {
return '/' . $path;
}
return $basePath . '/' . $path;
}
}

View File

@ -15,13 +15,8 @@ class AdminControllerTest extends TestCase
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
mkdir($this->tempLogDir . '/logs', 0777, true);
Log::init($this->tempLogDir . '/logs/tkr.log');
// Set up global config for logging level (DEBUG = 1)
global $config;
$config = new stdClass();
$config->logLevel = 1; // Allow DEBUG level logs
// Create mock PDO (needed for base constructor)
// Create mock PDO
$this->mockPdo = $this->createMock(PDO::class);
// Create real config and user objects with mocked PDO
@ -36,6 +31,17 @@ class AdminControllerTest extends TestCase
$this->user->username = 'testuser';
$this->user->displayName = 'Test User';
$this->user->website = 'https://example.com';
// Set up global $app for simplified dependency access
global $app;
$app = [
'db' => $this->mockPdo,
'config' => $this->config,
'user' => $this->user,
];
// Set log level on config for Log class
$this->config->logLevel = 1; // Allow DEBUG level logs
}
protected function tearDown(): void
@ -60,7 +66,7 @@ class AdminControllerTest extends TestCase
public function testGetAdminDataRegularMode(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$controller = new AdminController();
$data = $controller->getAdminData(false);
// Should return proper structure
@ -76,7 +82,7 @@ class AdminControllerTest extends TestCase
public function testGetAdminDataSetupMode(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$controller = new AdminController();
$data = $controller->getAdminData(true);
// Should return proper structure
@ -92,8 +98,8 @@ class AdminControllerTest extends TestCase
public function testProcessSettingsSaveWithEmptyData(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$result = $controller->processSettingsSave([], false);
$controller = new AdminController();
$result = $controller->saveSettings([], false);
$this->assertFalse($result['success']);
$this->assertContains('No data provided', $result['errors']);
@ -101,7 +107,7 @@ class AdminControllerTest extends TestCase
public function testProcessSettingsSaveValidationErrors(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$controller = new AdminController();
// Test data with multiple validation errors
$postData = [
@ -116,7 +122,7 @@ class AdminControllerTest extends TestCase
'confirm_password' => 'different' // Passwords don't match
];
$result = $controller->processSettingsSave($postData, false);
$result = $controller->saveSettings($postData, false);
$this->assertFalse($result['success']);
$this->assertNotEmpty($result['errors']);
@ -157,7 +163,12 @@ class AdminControllerTest extends TestCase
$config = new ConfigModel($this->mockPdo);
$user = new UserModel($this->mockPdo);
$controller = new AdminController($this->mockPdo, $config, $user);
// Update global $app with test models
global $app;
$app['config'] = $config;
$app['user'] = $user;
$controller = new AdminController();
$postData = [
'username' => 'newuser',
@ -172,7 +183,7 @@ class AdminControllerTest extends TestCase
'log_level' => 2
];
$result = $controller->processSettingsSave($postData, false);
$result = $controller->saveSettings($postData, false);
$this->assertTrue($result['success']);
$this->assertEmpty($result['errors']);
@ -214,7 +225,12 @@ class AdminControllerTest extends TestCase
$config = new ConfigModel($this->mockPdo);
$user = new UserModel($this->mockPdo);
$controller = new AdminController($this->mockPdo, $config, $user);
// Update global $app with test models
global $app;
$app['config'] = $config;
$app['user'] = $user;
$controller = new AdminController();
$postData = [
'username' => 'testuser',
@ -228,7 +244,7 @@ class AdminControllerTest extends TestCase
'confirm_password' => 'newpassword'
];
$result = $controller->processSettingsSave($postData, false);
$result = $controller->saveSettings($postData, false);
$this->assertTrue($result['success']);
}
@ -242,7 +258,12 @@ class AdminControllerTest extends TestCase
$config = new ConfigModel($this->mockPdo);
$user = new UserModel($this->mockPdo);
$controller = new AdminController($this->mockPdo, $config, $user);
// Update global $app with test models
global $app;
$app['config'] = $config;
$app['user'] = $user;
$controller = new AdminController();
$postData = [
'username' => 'testuser',
@ -254,7 +275,7 @@ class AdminControllerTest extends TestCase
'items_per_page' => 10
];
$result = $controller->processSettingsSave($postData, false);
$result = $controller->saveSettings($postData, false);
$this->assertFalse($result['success']);
$this->assertContains('Failed to save settings', $result['errors']);
@ -262,7 +283,7 @@ class AdminControllerTest extends TestCase
public function testLoggingOnAdminPageLoad(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$controller = new AdminController();
$controller->getAdminData(false);
// Check that logs were written
@ -275,7 +296,7 @@ class AdminControllerTest extends TestCase
public function testLoggingOnSetupPageLoad(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$controller = new AdminController();
$controller->getAdminData(true);
// Check that logs were written
@ -288,7 +309,7 @@ class AdminControllerTest extends TestCase
public function testLoggingOnValidationErrors(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$controller = new AdminController();
$postData = [
'username' => '', // Will cause validation error
@ -299,7 +320,7 @@ class AdminControllerTest extends TestCase
'items_per_page' => 10
];
$controller->processSettingsSave($postData, false);
$controller->saveSettings($postData, false);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
@ -341,7 +362,12 @@ class AdminControllerTest extends TestCase
$config = new ConfigModel($this->mockPdo);
$user = new UserModel($this->mockPdo);
$controller = new AdminController($this->mockPdo, $config, $user);
// Update global $app with test models
global $app;
$app['config'] = $config;
$app['user'] = $user;
$controller = new AdminController();
$postData = [
'username' => 'testuser',
@ -353,7 +379,7 @@ class AdminControllerTest extends TestCase
'items_per_page' => 10
];
$controller->processSettingsSave($postData, false);
$controller->saveSettings($postData, false);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';

View File

@ -16,11 +16,6 @@ class FeedControllerTest extends TestCase
mkdir($this->tempLogDir . '/logs', 0777, true);
Log::init($this->tempLogDir . '/logs/tkr.log');
// Set up global config for logging level (DEBUG = 1)
global $config;
$config = new stdClass();
$config->logLevel = 1; // Allow DEBUG level logs
// Create mock PDO and PDOStatement
$this->mockStatement = $this->createMock(PDOStatement::class);
$this->mockPdo = $this->createMock(PDO::class);
@ -36,6 +31,17 @@ class FeedControllerTest extends TestCase
// Mock user
$this->mockUser = new UserModel($this->mockPdo);
$this->mockUser->displayName = 'Test User';
// Set up global $app for simplified dependency access
global $app;
$app = [
'db' => $this->mockPdo,
'config' => $this->mockConfig,
'user' => $this->mockUser,
];
// Set log level on config for Log class
$this->mockConfig->logLevel = 1; // Allow DEBUG level logs
}
protected function tearDown(): void
@ -77,7 +83,7 @@ class FeedControllerTest extends TestCase
{
$this->setupMockDatabase([]);
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new FeedController();
// Verify it was created successfully
$this->assertInstanceOf(FeedController::class, $controller);
@ -99,7 +105,7 @@ class FeedControllerTest extends TestCase
$this->setupMockDatabase($testTicks);
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new FeedController();
// Verify it was created successfully
$this->assertInstanceOf(FeedController::class, $controller);
@ -127,7 +133,7 @@ class FeedControllerTest extends TestCase
->method('execute')
->with([10, 0]); // itemsPerPage=10, page 1 = offset 0
new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
new FeedController();
}
public function testRssMethodLogsCorrectly(): void
@ -138,7 +144,7 @@ class FeedControllerTest extends TestCase
$this->setupMockDatabase($testTicks);
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new FeedController();
// Capture output to prevent headers/content from affecting test
ob_start();
@ -160,7 +166,7 @@ class FeedControllerTest extends TestCase
$this->setupMockDatabase($testTicks);
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new FeedController();
// Capture output to prevent headers/content from affecting test
ob_start();

View File

@ -16,11 +16,6 @@ class HomeControllerTest extends TestCase
mkdir($this->tempLogDir . '/logs', 0777, true);
Log::init($this->tempLogDir . '/logs/tkr.log');
// Set up global config for logging level (DEBUG = 1)
global $config;
$config = new stdClass();
$config->logLevel = 1; // Allow DEBUG level logs
// Create mock PDO and PDOStatement
$this->mockStatement = $this->createMock(PDOStatement::class);
$this->mockPdo = $this->createMock(PDO::class);
@ -34,6 +29,17 @@ class HomeControllerTest extends TestCase
$this->mockUser = new UserModel($this->mockPdo);
$this->mockUser->displayName = 'Test User';
$this->mockUser->mood = '😊';
// Set up global $app for simplified dependency access
global $app;
$app = [
'db' => $this->mockPdo,
'config' => $this->mockConfig,
'user' => $this->mockUser,
];
// Set log level on config for Log class
$this->mockConfig->logLevel = 1; // Allow DEBUG level logs
}
protected function tearDown(): void
@ -91,7 +97,7 @@ class HomeControllerTest extends TestCase
{
$this->setupMockDatabase([]); // Empty array = no ticks
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$data = $controller->getHomeData(1);
// Should return proper structure
@ -118,7 +124,7 @@ class HomeControllerTest extends TestCase
$this->setupMockDatabase($testTicks);
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$data = $controller->getHomeData(1);
// Should return proper structure
@ -147,7 +153,7 @@ class HomeControllerTest extends TestCase
->method('execute')
->with([10, 10]); // itemsPerPage=10, page 2 = offset 10
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$controller->getHomeData(2); // Page 2
}
@ -171,7 +177,7 @@ class HomeControllerTest extends TestCase
&& $params[1] === 'This is a test tick';
}));
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$postData = ['new_tick' => 'This is a test tick'];
$result = $controller->processTick($postData);
@ -185,7 +191,7 @@ class HomeControllerTest extends TestCase
// PDO shouldn't be called at all for empty content
$this->mockPdo->expects($this->never())->method('prepare');
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$postData = ['new_tick' => ' ']; // Just whitespace
$result = $controller->processTick($postData);
@ -199,7 +205,7 @@ class HomeControllerTest extends TestCase
// PDO shouldn't be called at all for missing field
$this->mockPdo->expects($this->never())->method('prepare');
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$postData = []; // No new_tick field
$result = $controller->processTick($postData);
@ -219,7 +225,7 @@ class HomeControllerTest extends TestCase
return $params[1] === 'This has whitespace'; // Should be trimmed
}));
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$postData = ['new_tick' => ' This has whitespace '];
$result = $controller->processTick($postData);
@ -231,7 +237,7 @@ class HomeControllerTest extends TestCase
{
$this->setupMockDatabaseForInsert(false); // Will throw exception
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$postData = ['new_tick' => 'This will fail'];
$result = $controller->processTick($postData);
@ -247,7 +253,7 @@ class HomeControllerTest extends TestCase
];
$this->setupMockDatabase($testTicks);
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$controller->getHomeData(1);
// Check that logs were written
@ -263,7 +269,7 @@ class HomeControllerTest extends TestCase
{
$this->setupMockDatabaseForInsert(true);
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$postData = ['new_tick' => 'Test tick for logging'];
$controller->processTick($postData);
@ -278,7 +284,7 @@ class HomeControllerTest extends TestCase
public function testLoggingOnEmptyTick(): void
{
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$postData = ['new_tick' => ''];
$controller->processTick($postData);
@ -297,7 +303,7 @@ class HomeControllerTest extends TestCase
{
$this->setupMockDatabaseForInsert(false);
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller = new HomeController();
$postData = ['new_tick' => 'This will fail'];
$controller->processTick($postData);

View File

@ -19,12 +19,20 @@ class LogControllerTest extends TestCase
$this->originalGet = $_GET;
$_GET = [];
// Mock global config
global $config;
// Set up global $app for simplified dependency access
$mockPdo = $this->createMock(PDO::class);
$config = new ConfigModel($mockPdo);
$config->baseUrl = 'https://example.com';
$config->basePath = '/tkr/';
$mockConfig = new ConfigModel($mockPdo);
$mockConfig->baseUrl = 'https://example.com';
$mockConfig->basePath = '/tkr/';
$mockUser = new UserModel($mockPdo);
global $app;
$app = [
'db' => $mockPdo,
'config' => $mockConfig,
'user' => $mockUser,
];
}
protected function tearDown(): void
@ -51,10 +59,8 @@ class LogControllerTest extends TestCase
public function testGetLogDataWithNoLogFiles(): void
{
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
// Uses global $app set up in setUp()
$controller = new LogController($this->tempLogDir);
$data = $controller->getLogData();
// Should return empty log entries but valid structure
@ -85,10 +91,8 @@ class LogControllerTest extends TestCase
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
// Uses global $app set up in setUp()
$controller = new LogController($this->tempLogDir);
$data = $controller->getLogData();
// Should parse all valid entries and ignore invalid ones
@ -129,10 +133,8 @@ class LogControllerTest extends TestCase
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
// Uses global $app set up in setUp()
$controller = new LogController($this->tempLogDir);
$data = $controller->getLogData('ERROR');
// Should only include ERROR entries
@ -152,10 +154,8 @@ class LogControllerTest extends TestCase
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
// Uses global $app set up in setUp()
$controller = new LogController($this->tempLogDir);
$data = $controller->getLogData('', 'GET /admin');
// Should only include GET /admin entries
@ -175,10 +175,8 @@ class LogControllerTest extends TestCase
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
// Uses global $app set up in setUp()
$controller = new LogController($this->tempLogDir);
$data = $controller->getLogData('ERROR', 'GET /admin');
// Should only include entries matching both filters
@ -201,10 +199,8 @@ class LogControllerTest extends TestCase
$rotatedLog2 = '[2025-01-31 12:00:00] WARNING: 127.0.0.1 - Rotated log entry 2';
file_put_contents($this->testLogFile . '.2', $rotatedLog2);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
// Uses global $app set up in setUp()
$controller = new LogController($this->tempLogDir);
$data = $controller->getLogData();
// Should read from all log files, newest first
@ -226,10 +222,8 @@ class LogControllerTest extends TestCase
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
// Uses global $app set up in setUp()
$controller = new LogController($this->tempLogDir);
$data = $controller->getLogData();
// Should extract unique routes, sorted
@ -248,10 +242,8 @@ class LogControllerTest extends TestCase
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
// Uses global $app set up in setUp()
$controller = new LogController($this->tempLogDir);
$data = $controller->getLogData();
// Should only include valid entries, ignore invalid ones

View File

@ -43,10 +43,11 @@ class LogTest extends TestCase
{
Log::setRouteContext('GET /admin');
// Create a mock config for log level
global $config;
$config = new stdClass();
$config->logLevel = 1; // DEBUG level
// Create a mock app config for log level
global $app;
$app = [
'config' => (object)['logLevel' => 1] // DEBUG level
];
Log::debug('Test message');
@ -61,9 +62,10 @@ class LogTest extends TestCase
{
Log::setRouteContext('');
global $config;
$config = new stdClass();
$config->logLevel = 1;
global $app;
$app = [
'config' => (object)['logLevel' => 1]
];
Log::info('Test without route');
@ -78,9 +80,10 @@ class LogTest extends TestCase
public function testLogLevelFiltering(): void
{
global $config;
$config = new stdClass();
$config->logLevel = 3; // WARNING level
global $app;
$app = [
'config' => (object)['logLevel' => 3] // WARNING level
];
Log::debug('Debug message'); // Should be filtered out
Log::info('Info message'); // Should be filtered out
@ -99,9 +102,10 @@ class LogTest extends TestCase
{
Log::setRouteContext('POST /admin');
global $config;
$config = new stdClass();
$config->logLevel = 1;
global $app;
$app = [
'config' => (object)['logLevel' => 1]
];
Log::error('Test error message');
@ -129,9 +133,10 @@ class LogTest extends TestCase
public function testLogRotation(): void
{
global $config;
$config = new stdClass();
$config->logLevel = 1;
global $app;
$app = [
'config' => (object)['logLevel' => 1]
];
// Create a log file with exactly 1000 lines (the rotation threshold)
$logLines = str_repeat("[2025-01-31 12:00:00] INFO: 127.0.0.1 - Test line\n", 1000);
@ -154,9 +159,11 @@ class LogTest extends TestCase
public function testDefaultLogLevelWhenConfigMissing(): void
{
// Clear global config
global $config;
$config = null;
// Set up config without logLevel property (simulates missing config value)
global $app;
$app = [
'config' => (object)[] // Empty config object, no logLevel property
];
// Should not throw errors and should default to INFO level
Log::debug('Debug message'); // Should be filtered out (default INFO level = 2)

View File

@ -73,4 +73,134 @@ final class UtilTest extends TestCase
$this->assertEquals($expected, $result);
}
// Test data for escape_html function
public static function escapeHtmlProvider(): array {
return [
'basic HTML' => ['<script>alert("xss")</script>', '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'],
'quotes' => ['He said "Hello" & she said \'Hi\'', 'He said &quot;Hello&quot; &amp; she said &#039;Hi&#039;'],
'empty string' => ['', ''],
'normal text' => ['Hello World', 'Hello World'],
'ampersand' => ['Tom & Jerry', 'Tom &amp; Jerry'],
'unicode' => ['🚀 emoji & text', '🚀 emoji &amp; text'],
];
}
#[DataProvider('escapeHtmlProvider')]
public function testEscapeHtml(string $input, string $expected): void {
$result = Util::escape_html($input);
$this->assertEquals($expected, $result);
}
// Test data for escape_xml function
public static function escapeXmlProvider(): array {
return [
'basic XML' => ['<tag attr="value">content</tag>', '&lt;tag attr=&quot;value&quot;&gt;content&lt;/tag&gt;'],
'quotes and ampersand' => ['Title & "Subtitle"', 'Title &amp; &quot;Subtitle&quot;'],
'empty string' => ['', ''],
'normal text' => ['Hello World', 'Hello World'],
'unicode' => ['🎵 music & notes', '🎵 music &amp; notes'],
];
}
#[DataProvider('escapeXmlProvider')]
public function testEscapeXml(string $input, string $expected): void {
$result = Util::escape_xml($input);
$this->assertEquals($expected, $result);
}
// Test data for linkify function
public static function linkifyProvider(): array {
return [
'simple URL' => [
'Check out https://example.com for more info',
'Check out <a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a> for more info',
false // not strict accessibility
],
'URL with path' => [
'Visit https://example.com/path/to/page',
'Visit <a href="https://example.com/path/to/page" target="_blank" rel="noopener noreferrer">https://example.com/path/to/page</a>',
false
],
'multiple URLs' => [
'See https://example.com and https://other.com',
'See <a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a> and <a href="https://other.com" target="_blank" rel="noopener noreferrer">https://other.com</a>',
false
],
'URL with punctuation' => [
'Check https://example.com.',
'Check <a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a>',
false
],
'no URL' => [
'Just some regular text',
'Just some regular text',
false
],
'strict accessibility mode' => [
'Visit https://example.com now',
'Visit <a tabindex="0" href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a> now',
true // strict accessibility
],
];
}
#[DataProvider('linkifyProvider')]
public function testLinkify(string $input, string $expected, bool $strictAccessibility): void {
// Set up global $app with config
global $app;
$app = [
'config' => (object)['strictAccessibility' => $strictAccessibility]
];
$result = Util::linkify($input);
$this->assertEquals($expected, $result);
}
public function testLinkifyNoNewWindow(): void {
// Test linkify without new window
global $app;
$app = [
'config' => (object)['strictAccessibility' => false]
];
$input = 'Visit https://example.com';
$expected = 'Visit <a href="https://example.com">https://example.com</a>';
$result = Util::linkify($input, false); // no new window
$this->assertEquals($expected, $result);
}
public function testGetClientIp(): void {
// Test basic case with REMOTE_ADDR
$_SERVER['REMOTE_ADDR'] = '192.168.1.100';
unset($_SERVER['HTTP_CLIENT_IP'], $_SERVER['HTTP_X_FORWARDED_FOR'], $_SERVER['HTTP_X_REAL_IP']);
$result = Util::getClientIp();
$this->assertEquals('192.168.1.100', $result);
}
public function testGetClientIpWithForwardedHeaders(): void {
// Test precedence: HTTP_CLIENT_IP > HTTP_X_FORWARDED_FOR > HTTP_X_REAL_IP > REMOTE_ADDR
$_SERVER['HTTP_CLIENT_IP'] = '10.0.0.1';
$_SERVER['HTTP_X_FORWARDED_FOR'] = '10.0.0.2';
$_SERVER['HTTP_X_REAL_IP'] = '10.0.0.3';
$_SERVER['REMOTE_ADDR'] = '10.0.0.4';
$result = Util::getClientIp();
$this->assertEquals('10.0.0.1', $result); // Should use HTTP_CLIENT_IP
}
public function testGetClientIpUnknown(): void {
// Test when no IP is available
unset($_SERVER['HTTP_CLIENT_IP'], $_SERVER['HTTP_X_FORWARDED_FOR'], $_SERVER['HTTP_X_REAL_IP'], $_SERVER['REMOTE_ADDR']);
$result = Util::getClientIp();
$this->assertEquals('unknown', $result);
}
protected function tearDown(): void {
// Clean up $_SERVER after IP tests
unset($_SERVER['HTTP_CLIENT_IP'], $_SERVER['HTTP_X_FORWARDED_FOR'], $_SERVER['HTTP_X_REAL_IP'], $_SERVER['REMOTE_ADDR']);
}
}