Change ConfigModel to SettingsModel (#62)
Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/62 Co-authored-by: Greg Sarjeant <greg@subcultureofone.org> Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
		
							parent
							
								
									38c35f9bff
								
							
						
					
					
						commit
						3df38de9fb
					
				| @ -46,11 +46,11 @@ $db = $prerequisites->getDatabase(); | ||||
| if (!(preg_match('/tkr-setup$/', $path))) { | ||||
|     try { | ||||
|         $user_count = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn(); | ||||
|         $config = (new ConfigModel($db))->get(); | ||||
|          | ||||
|         $settings = (new SettingsModel($db))->get(); | ||||
| 
 | ||||
|         $hasUser = $user_count > 0; | ||||
|         $hasUrl = !empty($config->baseUrl) && !empty($config->basePath); | ||||
|          | ||||
|         $hasUrl = !empty($settings->baseUrl) && !empty($settings->basePath); | ||||
| 
 | ||||
|         if (!$hasUser || !$hasUrl) { | ||||
|             // Redirect to setup with auto-detected URL
 | ||||
|             $autodetected = Util::getAutodetectedUrl(); | ||||
| @ -77,7 +77,7 @@ global $app; | ||||
| 
 | ||||
| $app = [ | ||||
|     'db' => $db, | ||||
|     'config' => (new ConfigModel($db))->get(), | ||||
|     'settings' => (new SettingsModel($db))->get(), | ||||
|     'user' => (new UserModel($db))->get(), | ||||
| ]; | ||||
| 
 | ||||
| @ -86,8 +86,8 @@ Session::start(); | ||||
| Session::generateCsrfToken(); | ||||
| 
 | ||||
| // Remove the base path from the URL
 | ||||
| if (strpos($path, $app['config']->basePath) === 0) { | ||||
|     $path = substr($path, strlen($app['config']->basePath)); | ||||
| if (strpos($path, $app['settings']->basePath) === 0) { | ||||
|     $path = substr($path, strlen($app['settings']->basePath)); | ||||
| } | ||||
| 
 | ||||
| // strip the trailing slash from the resulting route
 | ||||
| @ -105,7 +105,7 @@ if ($method === 'POST' && $path != 'setup') { | ||||
|         if (!Session::isValid($_POST['csrf_token'])) { | ||||
|             // Invalid session - redirect to /login
 | ||||
|             Log::info('Attempt to POST with invalid session. Redirecting to login.'); | ||||
|             header('Location: ' . Util::buildRelativeUrl($app['config']->basePath, 'login')); | ||||
|             header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, 'login')); | ||||
|             exit; | ||||
|         } | ||||
|     } else { | ||||
|  | ||||
| @ -11,41 +11,41 @@ class AdminController extends Controller { | ||||
| 
 | ||||
|     public function showSetup(){ | ||||
|         $data = $this->getAdminData(true); | ||||
|          | ||||
| 
 | ||||
|         // Auto-detect URL and pre-fill if not already configured
 | ||||
|         if (empty($data['config']->baseUrl) || empty($data['config']->basePath)) { | ||||
|         if (empty($data['settings']->baseUrl) || empty($data['settings']->basePath)) { | ||||
|             $autodetected = Util::getAutodetectedUrl(); | ||||
|             $data['autodetectedUrl'] = $autodetected; | ||||
|              | ||||
| 
 | ||||
|             // Pre-fill empty values with auto-detected ones
 | ||||
|             if (empty($data['config']->baseUrl)) { | ||||
|                 $data['config']->baseUrl = $autodetected['baseUrl']; | ||||
|             if (empty($data['settings']->baseUrl)) { | ||||
|                 $data['settings']->baseUrl = $autodetected['baseUrl']; | ||||
|             } | ||||
|             if (empty($data['config']->basePath)) { | ||||
|                 $data['config']->basePath = $autodetected['basePath']; | ||||
|             if (empty($data['settings']->basePath)) { | ||||
|                 $data['settings']->basePath = $autodetected['basePath']; | ||||
|             } | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         $this->render("admin.php", $data); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     public function getAdminData(bool $isSetup): array { | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         Log::debug("Loading admin page" . ($isSetup ? " (setup mode)" : "")); | ||||
|          | ||||
| 
 | ||||
|         return [ | ||||
|             'user' => $app['user'], | ||||
|             'config' => $app['config'], | ||||
|             'settings' => $app['settings'], | ||||
|             'isSetup' => $isSetup, | ||||
|         ]; | ||||
|     } | ||||
| 
 | ||||
|     public function handleSave(){ | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         if (!Session::isLoggedIn()){ | ||||
|             header('Location: ' . Util::buildRelativeUrl($app['config']->basePath, 'login')); | ||||
|             header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, 'login')); | ||||
|             exit; | ||||
|         } | ||||
| 
 | ||||
| @ -64,9 +64,9 @@ class AdminController extends Controller { | ||||
| 
 | ||||
|     public function saveSettings(array $postData, bool $isSetup): array { | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         $result = ['success' => false, 'errors' => []]; | ||||
|          | ||||
| 
 | ||||
|         Log::debug("Processing settings save" . ($isSetup ? " (setup mode)" : "")); | ||||
| 
 | ||||
|         // handle form submission
 | ||||
| @ -95,7 +95,7 @@ class AdminController extends Controller { | ||||
|         // Password
 | ||||
|         $password        = $postData['password'] ?? ''; | ||||
|         $confirmPassword = $postData['confirm_password'] ?? ''; | ||||
|          | ||||
| 
 | ||||
|         Log::info("Processing settings for user: $username"); | ||||
| 
 | ||||
|         // Validate user profile
 | ||||
| @ -145,16 +145,16 @@ class AdminController extends Controller { | ||||
|         if (empty($errors)) { | ||||
|             try { | ||||
|                 // Update site settings
 | ||||
|                 $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; | ||||
|                 $app['settings']->siteTitle = $siteTitle; | ||||
|                 $app['settings']->siteDescription = $siteDescription; | ||||
|                 $app['settings']->baseUrl = $baseUrl; | ||||
|                 $app['settings']->basePath = $basePath; | ||||
|                 $app['settings']->itemsPerPage = $itemsPerPage; | ||||
|                 $app['settings']->strictAccessibility = $strictAccessibility; | ||||
|                 $app['settings']->logLevel = $logLevel; | ||||
| 
 | ||||
|                 // Save site settings and reload config from database
 | ||||
|                 $app['config'] = $app['config']->save(); | ||||
|                 $app['settings'] = $app['settings']->save(); | ||||
|                 Log::info("Site settings updated"); | ||||
| 
 | ||||
|                 // Update user profile
 | ||||
| @ -174,7 +174,7 @@ class AdminController extends Controller { | ||||
| 
 | ||||
|                 Session::setFlashMessage('success', 'Settings updated'); | ||||
|                 $result['success'] = true; | ||||
|                  | ||||
| 
 | ||||
|             } catch (Exception $e) { | ||||
|                 Log::error("Failed to save settings: " . $e->getMessage()); | ||||
|                 Session::setFlashMessage('error', 'Failed to save settings'); | ||||
| @ -186,7 +186,7 @@ class AdminController extends Controller { | ||||
|             } | ||||
|             $result['errors'] = $errors; | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         return $result; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -4,11 +4,11 @@ declare(strict_types=1); | ||||
| class AuthController extends Controller { | ||||
|     function showLogin(?string $error = null){ | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         $csrf_token = Session::getCsrfToken(); | ||||
| 
 | ||||
|         $vars = [ | ||||
|             'config' => $app['config'], | ||||
|             'settings' => $app['settings'], | ||||
|             'csrf_token' => $csrf_token, | ||||
|             'error' => $error, | ||||
|         ]; | ||||
| @ -34,7 +34,7 @@ class AuthController extends Controller { | ||||
| 
 | ||||
|                     try { | ||||
|                         Session::newLoginSession($user); | ||||
|                         header('Location: ' . Util::buildRelativeUrl($app['config']->basePath)); | ||||
|                         header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath)); | ||||
|                         exit; | ||||
|                     } catch (Exception $e) { | ||||
|                         Log::error("Failed to create login session for {$username}: " . $e->getMessage()); | ||||
| @ -61,11 +61,11 @@ class AuthController extends Controller { | ||||
| 
 | ||||
|     function handleLogout(){ | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         Log::info("Logout from user " . $_SESSION['username']); | ||||
|         Session::end(); | ||||
| 
 | ||||
|         header('Location: ' . Util::buildRelativeUrl($app['config']->basePath)); | ||||
|         header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath)); | ||||
|         exit; | ||||
|     } | ||||
| } | ||||
| @ -17,9 +17,9 @@ class Controller { | ||||
| 
 | ||||
|         // Add custom CSS filename if needed
 | ||||
|         global $app; | ||||
|         if ($app['config']->cssId) { | ||||
|         if ($app['settings']->cssId) { | ||||
|             $cssModel = new CssModel($app['db']); | ||||
|             $cssFile = $cssModel->getById($app['config']->cssId); | ||||
|             $cssFile = $cssModel->getById($app['settings']->cssId); | ||||
|             $vars['customCssFilename'] = $cssFile['filename'] ?? null; | ||||
|         } else { | ||||
|             $vars['customCssFilename'] = null; | ||||
|  | ||||
| @ -9,7 +9,7 @@ class CssController extends Controller { | ||||
| 
 | ||||
|         $vars = [ | ||||
|             'user' => $app['user'], | ||||
|             'config' => $app['config'], | ||||
|             'settings' => $app['settings'], | ||||
|             'customCss' => $customCss, | ||||
|         ]; | ||||
| 
 | ||||
| @ -114,8 +114,8 @@ class CssController extends Controller { | ||||
| 
 | ||||
|         // Set the theme back to default
 | ||||
|         try { | ||||
|             $app['config']->cssId = null; | ||||
|             $app['config'] = $app['config']->save(); | ||||
|             $app['settings']->cssId = null; | ||||
|             $app['settings'] = $app['settings']->save(); | ||||
|             Session::setFlashMessage('success', 'Theme ' . $cssFilename . ' deleted.'); | ||||
|         } catch (Exception $e) { | ||||
|             Log::error("Failed to update config after deleting theme: " . $e->getMessage()); | ||||
| @ -129,14 +129,14 @@ class CssController extends Controller { | ||||
|         try { | ||||
|             if ($_POST['selectCssFile']){ | ||||
|                 // Set custom theme
 | ||||
|                 $app['config']->cssId = $_POST['selectCssFile']; | ||||
|                 $app['settings']->cssId = $_POST['selectCssFile']; | ||||
|             } else { | ||||
|                 // Set default theme
 | ||||
|                 $app['config']->cssId = null; | ||||
|                 $app['settings']->cssId = null; | ||||
|             } | ||||
| 
 | ||||
|             // Update the site theme
 | ||||
|             $app['config'] = $app['config']->save(); | ||||
|             $app['settings'] = $app['settings']->save(); | ||||
|             Session::setFlashMessage('success', 'Theme applied.'); | ||||
|         } catch (Exception $e) { | ||||
|             Log::error("Failed to save theme setting: " . $e->getMessage()); | ||||
|  | ||||
| @ -16,7 +16,7 @@ declare(strict_types=1); | ||||
|             } | ||||
| 
 | ||||
|             $vars = [ | ||||
|                 'config' => $app['config'], | ||||
|                 'settings' => $app['settings'], | ||||
|                 'emojiList' => $emojiList, | ||||
|             ]; | ||||
| 
 | ||||
| @ -39,7 +39,7 @@ declare(strict_types=1); | ||||
|                 break; | ||||
|             } | ||||
| 
 | ||||
|             header('Location: ' . Util::buildRelativeUrl($app['config']->basePath, 'admin/emoji')); | ||||
|             header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, 'admin/emoji')); | ||||
|             exit; | ||||
|         } | ||||
| 
 | ||||
|  | ||||
| @ -6,10 +6,10 @@ class FeedController extends Controller { | ||||
| 
 | ||||
|     public function __construct() { | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         try { | ||||
|             $tickModel = new TickModel($app['db'], $app['config']); | ||||
|             $this->ticks = $tickModel->getPage($app['config']->itemsPerPage); | ||||
|             $tickModel = new TickModel($app['db'], $app['settings']); | ||||
|             $this->ticks = $tickModel->getPage($app['settings']->itemsPerPage); | ||||
|             Log::debug("Loaded " . count($this->ticks) . " ticks for feeds"); | ||||
|         } catch (Exception $e) { | ||||
|             Log::error("Failed to load ticks for feed: " . $e->getMessage()); | ||||
| @ -20,8 +20,8 @@ class FeedController extends Controller { | ||||
| 
 | ||||
|     public function rss(){ | ||||
|         global $app; | ||||
|          | ||||
|         $generator = new RssGenerator($app['config'], $this->ticks); | ||||
| 
 | ||||
|         $generator = new RssGenerator($app['settings'], $this->ticks); | ||||
|         Log::debug("Generating RSS feed with " . count($this->ticks) . " ticks"); | ||||
| 
 | ||||
|         header('Content-Type: ' . $generator->getContentType()); | ||||
| @ -30,8 +30,8 @@ class FeedController extends Controller { | ||||
| 
 | ||||
|     public function atom(){ | ||||
|         global $app; | ||||
|          | ||||
|         $generator = new AtomGenerator($app['config'], $this->ticks); | ||||
| 
 | ||||
|         $generator = new AtomGenerator($app['settings'], $this->ticks); | ||||
|         Log::debug("Generating Atom feed with " . count($this->ticks) . " ticks"); | ||||
| 
 | ||||
|         header('Content-Type: ' . $generator->getContentType()); | ||||
|  | ||||
| @ -9,24 +9,24 @@ class HomeController extends Controller { | ||||
|         $data = $this->getHomeData($page); | ||||
|         $this->render("home.php", $data); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     public function getHomeData(int $page): array { | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         Log::debug("Loading home page $page"); | ||||
| 
 | ||||
|         $tickModel = new TickModel($app['db'], $app['config']); | ||||
|         $limit = $app['config']->itemsPerPage; | ||||
|         $tickModel = new TickModel($app['db'], $app['settings']); | ||||
|         $limit = $app['settings']->itemsPerPage; | ||||
|         $offset = ($page - 1) * $limit; | ||||
|         $ticks = $tickModel->getPage($limit, $offset); | ||||
| 
 | ||||
|         $view = new TicksView($app['config'], $ticks, $page); | ||||
|         $view = new TicksView($app['settings'], $ticks, $page); | ||||
|         $tickList = $view->getHtml(); | ||||
| 
 | ||||
|         Log::info("Home page loaded with " . count($ticks) . " ticks"); | ||||
| 
 | ||||
|         return [ | ||||
|             'config'     => $app['config'], | ||||
|             'settings'     => $app['settings'], | ||||
|             'user'       => $app['user'], | ||||
|             'tickList'   => $tickList, | ||||
|         ]; | ||||
| @ -36,34 +36,34 @@ class HomeController extends Controller { | ||||
|     // 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($app['config']->basePath)); | ||||
|         header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath)); | ||||
|         exit; | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     public function processTick(array $postData): array { | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         $result = ['success' => false, 'message' => '']; | ||||
|          | ||||
| 
 | ||||
|         if (!isset($postData['new_tick'])) { | ||||
|             Log::warning("Tick submission without new_tick field"); | ||||
|             $result['message'] = 'No tick content provided'; | ||||
|             return $result; | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         $tickContent = trim($postData['new_tick']); | ||||
|         if (empty($tickContent)) { | ||||
|             Log::debug("Empty tick submission ignored"); | ||||
|             $result['message'] = 'Empty tick ignored'; | ||||
|             return $result; | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         try { | ||||
|             $tickModel = new TickModel($app['db'], $app['config']); | ||||
|             $tickModel = new TickModel($app['db'], $app['settings']); | ||||
|             $tickModel->insert($tickContent); | ||||
|             Log::info("New tick created: " . substr($tickContent, 0, 50) . (strlen($tickContent) > 50 ? '...' : '')); | ||||
|             $result['success'] = true; | ||||
| @ -72,7 +72,7 @@ class HomeController extends Controller { | ||||
|             Log::error("Failed to save tick: " . $e->getMessage()); | ||||
|             $result['message'] = 'Failed to save tick'; | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         return $result; | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -10,10 +10,10 @@ class LogController extends Controller { | ||||
| 
 | ||||
|     public function index() { | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         // Ensure user is logged in
 | ||||
|         if (!Session::isLoggedIn()) { | ||||
|             header('Location: ' . Util::buildRelativeUrl($app['config']->basePath, 'login')); | ||||
|             header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, 'login')); | ||||
|             exit; | ||||
|         } | ||||
| 
 | ||||
| @ -49,7 +49,7 @@ class LogController extends Controller { | ||||
|         } | ||||
| 
 | ||||
|         return [ | ||||
|             'config' => $app['config'], | ||||
|             'settings' => $app['settings'], | ||||
|             'logEntries' => $logEntries, | ||||
|             'availableRoutes' => $availableRoutes, | ||||
|             'availableLevels' => $availableLevels, | ||||
| @ -80,7 +80,7 @@ class LogController extends Controller { | ||||
|                             Log::warning("Failed to read log file: $file"); | ||||
|                             continue; | ||||
|                         } | ||||
|                          | ||||
| 
 | ||||
|                         foreach (array_reverse($lines) as $line) { | ||||
|                             if (count($entries) >= $limit) break 2; | ||||
| 
 | ||||
|  | ||||
| @ -10,7 +10,7 @@ declare(strict_types=1); | ||||
|             $moodPicker = $view->renderMoodPicker(self::getEmojisWithLabels(), $app['user']->mood); | ||||
| 
 | ||||
|             $vars = [ | ||||
|                 'config' => $app['config'], | ||||
|                 'settings' => $app['settings'], | ||||
|                 'moodPicker' => $moodPicker, | ||||
|             ]; | ||||
| 
 | ||||
| @ -41,7 +41,7 @@ declare(strict_types=1); | ||||
|                 } | ||||
| 
 | ||||
|                 // go back to the index and show the updated mood
 | ||||
|                 header('Location: ' . Util::buildRelativeUrl($app['config']->basePath)); | ||||
|                 header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath)); | ||||
|                 exit; | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @ -4,23 +4,23 @@ declare(strict_types=1); | ||||
| class TickController extends Controller{ | ||||
|     public function index(int $id){ | ||||
|         global $app; | ||||
|          | ||||
| 
 | ||||
|         Log::debug("Fetching tick with ID: {$id}"); | ||||
|          | ||||
| 
 | ||||
|         try { | ||||
|             $tickModel = new TickModel($app['db'], $app['config']); | ||||
|             $tickModel = new TickModel($app['db'], $app['settings']); | ||||
|             $vars = $tickModel->get($id); | ||||
|              | ||||
| 
 | ||||
|             if (empty($vars) || !isset($vars['tick'])) { | ||||
|                 Log::warning("Tick not found for ID: {$id}"); | ||||
|                 http_response_code(404); | ||||
|                 echo '<h1>404 - Tick Not Found</h1>'; | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
| 
 | ||||
|             Log::info("Successfully loaded tick {$id}: " . substr($vars['tick'], 0, 50) . (strlen($vars['tick']) > 50 ? '...' : '')); | ||||
|             $this->render('tick.php', $vars); | ||||
|              | ||||
| 
 | ||||
|         } catch (Exception $e) { | ||||
|             Log::error("Failed to load tick {$id}: " . $e->getMessage()); | ||||
|             http_response_code(500); | ||||
|  | ||||
| @ -15,10 +15,10 @@ class AtomGenerator extends FeedGenerator { | ||||
|     } | ||||
| 
 | ||||
|     private function buildFeed(): string { | ||||
|         Log::debug("Building Atom feed for " . $this->config->siteTitle); | ||||
|         $feedTitle = Util::escape_xml($this->config->siteTitle . " Atom Feed"); | ||||
|         $siteUrl = Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath)); | ||||
|         $feedUrl = Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath, 'feed/atom')); | ||||
|         Log::debug("Building Atom feed for " . $this->settings->siteTitle); | ||||
|         $feedTitle = Util::escape_xml($this->settings->siteTitle . " Atom Feed"); | ||||
|         $siteUrl = Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath)); | ||||
|         $feedUrl = Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, 'feed/atom')); | ||||
|         $updated = date(DATE_ATOM, strtotime($this->ticks[0]['timestamp'] ?? 'now')); | ||||
| 
 | ||||
|         ob_start(); | ||||
| @ -33,7 +33,7 @@ class AtomGenerator extends FeedGenerator { | ||||
|   <updated><?php echo $updated ?></updated>
 | ||||
|   <id><?php echo $siteUrl ?></id>
 | ||||
|   <author> | ||||
|         <name><?= Util::escape_xml($this->config->siteTitle) ?></name>
 | ||||
|         <name><?= Util::escape_xml($this->settings->siteTitle) ?></name>
 | ||||
|   </author> | ||||
| <?php foreach ($this->ticks as $tick): | ||||
|     // build the tick entry components
 | ||||
|  | ||||
| @ -5,11 +5,11 @@ declare(strict_types=1); | ||||
| // Specific feeds (RSS, Atom, etc.) will inherit from this.
 | ||||
| // This will wrap the basic generator functionality.
 | ||||
| abstract class FeedGenerator { | ||||
|     protected $config; | ||||
|     protected $settings; | ||||
|     protected $ticks; | ||||
| 
 | ||||
|     public function __construct(ConfigModel $config, array $ticks) { | ||||
|         $this->config = $config; | ||||
|     public function __construct(SettingsModel $settings, array $ticks) { | ||||
|         $this->settings = $settings; | ||||
|         $this->ticks = $ticks; | ||||
|     } | ||||
| 
 | ||||
| @ -17,10 +17,10 @@ abstract class FeedGenerator { | ||||
|     abstract public function getContentType(): string; | ||||
| 
 | ||||
|     protected function buildTickUrl(int $tickId): string { | ||||
|         return Util::buildUrl($this->config->baseUrl, $this->config->basePath, "tick/{$tickId}"); | ||||
|         return Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, "tick/{$tickId}"); | ||||
|     } | ||||
| 
 | ||||
|     protected function getSiteUrl(): string { | ||||
|         return Util::buildUrl($this->config->baseUrl, $this->config->basePath); | ||||
|         return Util::buildUrl($this->settings->baseUrl, $this->settings->basePath); | ||||
|     } | ||||
| } | ||||
| @ -17,16 +17,16 @@ class RssGenerator extends FeedGenerator { | ||||
|     } | ||||
| 
 | ||||
|     private function buildChannel(): string { | ||||
|         Log::debug("Building RSS channel for " . $this->config->siteTitle); | ||||
|         Log::debug("Building RSS channel for " . $this->settings->siteTitle); | ||||
|         ob_start(); | ||||
|         ?>
 | ||||
| <channel> | ||||
|     <title><?php echo Util::escape_xml($this->config->siteTitle . ' RSS Feed') ?></title>
 | ||||
|     <link><?php echo Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath))?></link>
 | ||||
|     <atom:link href="<?php echo Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath, 'feed/rss'))?>" | ||||
|     <title><?php echo Util::escape_xml($this->settings->siteTitle . ' RSS Feed') ?></title>
 | ||||
|     <link><?php echo Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath))?></link>
 | ||||
|     <atom:link href="<?php echo Util::escape_xml(Util::buildUrl($this->settings->baseUrl, $this->settings->basePath, 'feed/rss'))?>" | ||||
|                rel="self" | ||||
|                type="application/rss+xml" /> | ||||
|     <description><?php echo Util::escape_xml($this->config->siteDescription) ?></description>
 | ||||
|     <description><?php echo Util::escape_xml($this->settings->siteDescription) ?></description>
 | ||||
|     <language>en-us</language> | ||||
|     <lastBuildDate><?php echo date(DATE_RSS); ?></lastBuildDate>
 | ||||
| <?php foreach ($this->ticks as $tick): | ||||
|  | ||||
| @ -52,7 +52,7 @@ class Log { | ||||
| 
 | ||||
|     private static function write($level, $message) { | ||||
|         global $app; | ||||
|         $logLevel = $app['config']->logLevel ?? self::LEVELS['INFO']; | ||||
|         $logLevel = $app['settings']->logLevel ?? self::LEVELS['INFO']; | ||||
| 
 | ||||
|         // Only log messages if they're at or above the configured log level.
 | ||||
|         if (self::LEVELS[$level] < $logLevel){ | ||||
|  | ||||
| @ -30,7 +30,7 @@ class Util { | ||||
|                 global $app; | ||||
|                 $escaped_url = rtrim($matches[1], '.,!?;:)]}>'); | ||||
|                 $clean_url = html_entity_decode($escaped_url, ENT_QUOTES, 'UTF-8'); | ||||
|                 $tabIndex = $app['config']->strictAccessibility ? ' tabindex="0"' : ''; | ||||
|                 $tabIndex = $app['settings']->strictAccessibility ? ' tabindex="0"' : ''; | ||||
| 
 | ||||
|                 return '<a' . $tabIndex . ' href="' . $clean_url . '"' . $link_attrs . '>' . $escaped_url . '</a>'; | ||||
|             }, | ||||
| @ -114,29 +114,29 @@ class Util { | ||||
|         // Detect base URL
 | ||||
|         $baseUrl = ($_SERVER['HTTPS'] ?? 'off') === 'on' ? 'https://' : 'http://'; | ||||
|         $baseUrl .= $_SERVER['HTTP_HOST'] ?? 'localhost'; | ||||
|          | ||||
| 
 | ||||
|         // Don't include standard ports in URL
 | ||||
|         $port = $_SERVER['SERVER_PORT'] ?? null; | ||||
|         if ($port && $port != 80 && $port != 443) { | ||||
|             $baseUrl .= ':' . $port; | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         // Detect base path from script location
 | ||||
|         $scriptName = $_SERVER['SCRIPT_NAME'] ?? '/index.php'; | ||||
|         $basePath = dirname($scriptName); | ||||
|          | ||||
| 
 | ||||
|         if ($basePath === '/' || $basePath === '.' || $basePath === '') { | ||||
|             $basePath = '/'; | ||||
|         } else { | ||||
|             $basePath = '/' . trim($basePath, '/') . '/'; | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         // Construct full URL
 | ||||
|         $fullUrl = $baseUrl; | ||||
|         if ($basePath !== '/') { | ||||
|             $fullUrl .= ltrim($basePath, '/'); | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         return [ | ||||
|             'baseUrl' => $baseUrl, | ||||
|             'basePath' => $basePath, | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
| 
 | ||||
| class ConfigModel { | ||||
| class SettingsModel { | ||||
|     // properties and default values
 | ||||
|     public string $siteTitle = 'My tkr'; | ||||
|     public string $siteDescription = ''; | ||||
| @ -2,8 +2,8 @@ | ||||
| declare(strict_types=1); | ||||
| 
 | ||||
| class TickModel { | ||||
|     public function __construct(private PDO $db, private ConfigModel $config) {} | ||||
|      | ||||
|     public function __construct(private PDO $db, private SettingsModel $settings) {} | ||||
| 
 | ||||
|     public function getPage(int $limit, int $offset = 0): array { | ||||
|         $stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?"); | ||||
|         $stmt->execute([$limit, $offset]); | ||||
| @ -32,7 +32,7 @@ class TickModel { | ||||
|         return [ | ||||
|             'tickTime' => $row['timestamp'], | ||||
|             'tick' => $row['tick'], | ||||
|             'config' => $this->config, | ||||
|             'settings' => $this->settings, | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -4,15 +4,15 @@ declare(strict_types=1); | ||||
| class TicksView { | ||||
|     private $html; | ||||
| 
 | ||||
|     public function __construct(ConfigModel $config, array $ticks, int $page){ | ||||
|         $this->html = $this->render($config, $ticks, $page); | ||||
|     public function __construct(SettingsModel $settings, array $ticks, int $page){ | ||||
|         $this->html = $this->render($settings, $ticks, $page); | ||||
|     } | ||||
| 
 | ||||
|     public function getHtml(): string { | ||||
|         return $this->html; | ||||
|     } | ||||
| 
 | ||||
|     private function render(ConfigModel $config, array $ticks, int $page): string{ | ||||
|     private function render(SettingsModel $settings, array $ticks, int $page): string{ | ||||
|         ob_start(); | ||||
|         ?>
 | ||||
| 
 | ||||
| @ -23,18 +23,18 @@ class TicksView { | ||||
|                     $relativeTime = Util::relative_time($tick['timestamp']); | ||||
|                 ?>
 | ||||
|                 <li class="tick" tabindex="0"> | ||||
|                     <time datetime="<?php echo $datetime->format('c') ?>"><?php echo Util::escape_html($relativeTime) ?></time>
 | ||||
|                     🗑️ <time datetime="<?php echo $datetime->format('c') ?>"><?php echo Util::escape_html($relativeTime) ?></time>
 | ||||
|                     <span class="tick-text"><?php echo Util::linkify(Util::escape_html($tick['tick'])) ?></span>
 | ||||
|                 </li> | ||||
|             <?php endforeach; ?>
 | ||||
|             </ul> | ||||
|             <div class="tick-pagination"> | ||||
|             <?php if ($page > 1): ?>
 | ||||
|                 <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                 <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                    href="?page=<?php echo $page - 1 ?>">« Newer</a> | ||||
|             <?php endif; ?>
 | ||||
|             <?php if (count($ticks) === $config->itemsPerPage): ?>
 | ||||
|                 <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|             <?php if (count($ticks) === $settings->itemsPerPage): ?>
 | ||||
|                 <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                    href="?page=<?php echo $page + 1 ?>">Older »</a> | ||||
|             <?php endif; ?>
 | ||||
|             </div> | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| <?php /** @var bool $isLoggedIn */ ?>
 | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var SettingsModel $settings */ ?>
 | ||||
| <?php /** @var UserModel $user */ ?>
 | ||||
| <?php /** @var string $childTemplateFile */ ?>
 | ||||
| <?php /** @var string $customCssFilename */ ?>
 | ||||
| @ -7,23 +7,23 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|     <head> | ||||
|         <title><?= $config->siteTitle ?></title>
 | ||||
|         <title><?= $settings->siteTitle ?></title>
 | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|         <link rel="stylesheet" | ||||
|               href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'css/default.css')) ?>"> | ||||
| <?php if (!empty($config->cssId)): ?>
 | ||||
|               href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'css/default.css')) ?>"> | ||||
| <?php if (!empty($settings->cssId)): ?>
 | ||||
|         <link rel="stylesheet" | ||||
|               href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'css/custom/' . $customCssFilename)) ?>"> | ||||
|               href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'css/custom/' . $customCssFilename)) ?>"> | ||||
| <?php endif; ?>
 | ||||
|         <link rel="alternate" | ||||
|               type="application/rss+xml" | ||||
|               title="<?php echo Util::escape_html($config->siteTitle) ?> RSS Feed" | ||||
|               href="<?php echo Util::escape_html($config->baseUrl . $config->basePath)?>feed/rss/"> | ||||
|               title="<?php echo Util::escape_html($settings->siteTitle) ?> RSS Feed" | ||||
|               href="<?php echo Util::escape_html($settings->baseUrl . $settings->basePath)?>feed/rss/"> | ||||
|         <link rel="alternate" | ||||
|               type="application/atom+xml" | ||||
|               title="<?php echo Util::escape_html($config->siteTitle) ?> Atom Feed" | ||||
|               href="<?php echo Util::escape_html($config->baseUrl . $config->basePath)?>feed/atom/"> | ||||
|               title="<?php echo Util::escape_html($settings->siteTitle) ?> Atom Feed" | ||||
|               href="<?php echo Util::escape_html($settings->baseUrl . $settings->basePath)?>feed/atom/"> | ||||
|     </head> | ||||
|     <body> | ||||
| <?php include TEMPLATES_DIR . '/partials/navbar.php'?>
 | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var SettingsModel $settings */ ?>
 | ||||
| <?php /** @var UserModel $user */ ?>
 | ||||
| <?php /** @var isSetup bool */ ?>
 | ||||
|         <h1><?php if ($isSetup): ?>Setup<?php else: ?>Admin<?php endif; ?></h1>
 | ||||
|         <main> | ||||
|             <form | ||||
|                 action="<?php echo Util::buildRelativeUrl($config->basePath, ($isSetup ? 'setup' : 'admin')) ?>" | ||||
|                 action="<?php echo Util::buildRelativeUrl($settings->basePath, ($isSetup ? 'setup' : 'admin')) ?>" | ||||
|                 method="post"> | ||||
|                 <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> | ||||
|                 <fieldset> | ||||
| @ -36,43 +36,43 @@ | ||||
|                         <input type="text" | ||||
|                             id="site_title" | ||||
|                             name="site_title" | ||||
|                             value="<?= Util::escape_html($config->siteTitle) ?>" | ||||
|                             value="<?= Util::escape_html($settings->siteTitle) ?>" | ||||
|                             required> | ||||
|                         <label for="site_description">Description <span class=required>*</span></label> | ||||
|                         <input type="text" | ||||
|                             id="site_description" | ||||
|                             name="site_description" | ||||
|                             value="<?= Util::escape_html($config->siteDescription) ?>"> | ||||
|                             value="<?= Util::escape_html($settings->siteDescription) ?>"> | ||||
|                         <label for="base_url">Base URL <span class=required>*</span></label> | ||||
|                         <input type="text" | ||||
|                             id="base_url" | ||||
|                             name="base_url" | ||||
|                             value="<?= Util::escape_html($config->baseUrl) ?>" | ||||
|                             value="<?= Util::escape_html($settings->baseUrl) ?>" | ||||
|                             required> | ||||
|                         <label for="base_path">Base path <span class=required>*</span></label> | ||||
|                         <input type="text" | ||||
|                             id="base_path" | ||||
|                             name="base_path" | ||||
|                             value="<?= Util::escape_html($config->basePath) ?>" | ||||
|                             value="<?= Util::escape_html($settings->basePath) ?>" | ||||
|                             required> | ||||
|                         <label for="items_per_page">Ticks per page (max 50) <span class=required>*</span></label> | ||||
|                         <input type="number" | ||||
|                             id="items_per_page" | ||||
|                             name="items_per_page" | ||||
|                             value="<?= $config->itemsPerPage ?>" min="1" max="50" | ||||
|                             value="<?= $settings->itemsPerPage ?>" min="1" max="50" | ||||
|                             required> | ||||
|                         <label for="strict_accessibility">Strict accessibility</label> | ||||
|                         <input type="checkbox" | ||||
|                                id="strict_accessibility" | ||||
|                                name="strict_accessibility" | ||||
|                                value="1" | ||||
|                                <?php if ($config->strictAccessibility): ?> checked <?php endif; ?>>
 | ||||
|                                <?php if ($settings->strictAccessibility): ?> checked <?php endif; ?>>
 | ||||
|                         <label for="strict_accessibility">Log Level</label> | ||||
|                         <select id="log_level" name="log_level"> | ||||
|                             <option value="1" <?= ($config->logLevel ?? 2) == 1 ? 'selected' : '' ?>>DEBUG</option>
 | ||||
|                             <option value="2" <?= ($config->logLevel ?? 2) == 2 ? 'selected' : '' ?>>INFO</option>
 | ||||
|                             <option value="3" <?= ($config->logLevel ?? 2) == 3 ? 'selected' : '' ?>>WARNING</option>
 | ||||
|                             <option value="4" <?= ($config->logLevel ?? 2) == 4 ? 'selected' : '' ?>>ERROR</option>
 | ||||
|                             <option value="1" <?= ($settings->logLevel ?? 2) == 1 ? 'selected' : '' ?>>DEBUG</option>
 | ||||
|                             <option value="2" <?= ($settings->logLevel ?? 2) == 2 ? 'selected' : '' ?>>INFO</option>
 | ||||
|                             <option value="3" <?= ($settings->logLevel ?? 2) == 3 ? 'selected' : '' ?>>WARNING</option>
 | ||||
|                             <option value="4" <?= ($settings->logLevel ?? 2) == 4 ? 'selected' : '' ?>>ERROR</option>
 | ||||
|                         </select> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|  | ||||
| @ -1,18 +1,18 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var SettingsModel $settings */ ?>
 | ||||
| <?php /** @var Array $customCss */ ?>
 | ||||
|         <h1>CSS Management</h1> | ||||
|         <main> | ||||
|             <form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/css') ?>" method="post" enctype="multipart/form-data"> | ||||
|             <form action="<?= Util::buildRelativeUrl($settings->basePath, 'admin/css') ?>" method="post" enctype="multipart/form-data"> | ||||
|                 <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> | ||||
|                 <fieldset> | ||||
|                     <legend>Manage</legend> | ||||
|                     <div class="fieldset-items"> | ||||
|                         <label for="selectCssFile">Select CSS File</label> | ||||
|                         <select id="selectCssFile" name="selectCssFile"> | ||||
|                             <option value="" <?php if(!$config->cssId): ?>selected<?php endif; ?>>Default</option>
 | ||||
|                             <option value="" <?php if(!$settings->cssId): ?>selected<?php endif; ?>>Default</option>
 | ||||
| <?php foreach ($customCss as $cssFile): ?>
 | ||||
|     <?php | ||||
|         if ((int) $cssFile['id'] == $config->cssId){ | ||||
|         if ((int) $cssFile['id'] == $settings->cssId){ | ||||
|             $cssDescription = $cssFile['description']; | ||||
|             $selected = "selected"; | ||||
|         } | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var SettingsModel $settings */ ?>
 | ||||
| <?php /** @var array $emojiList */ ?>
 | ||||
|         <h1>Emoji Management</h1> | ||||
|         <main> | ||||
|             <form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/emoji') ?>" method="post" enctype="multipart/form-data"> | ||||
|             <form action="<?= Util::buildRelativeUrl($settings->basePath, 'admin/emoji') ?>" method="post" enctype="multipart/form-data"> | ||||
|                 <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> | ||||
|                 <fieldset> | ||||
|                     <legend>Add Emoji</legend> | ||||
| @ -24,7 +24,7 @@ | ||||
|                 </fieldset> | ||||
|             </form> | ||||
| <?php if (!empty($emojiList)): ?>
 | ||||
|             <form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/emoji') ?>" method="post" enctype="multipart/form-data"> | ||||
|             <form action="<?= Util::buildRelativeUrl($settings->basePath, 'admin/emoji') ?>" method="post" enctype="multipart/form-data"> | ||||
|                 <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> | ||||
|                 <fieldset class="delete-emoji-fieldset"> | ||||
|                     <legend>Delete Emoji</legend> | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| <?php /** @var bool $isLoggedIn */ ?>
 | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var SettingsModel $settings */ ?>
 | ||||
| <?php /** @var UserModel $user */ ?>
 | ||||
| <?php /** @var string $tickList */ ?>
 | ||||
|         <div class="home-container"> | ||||
| @ -13,8 +13,8 @@ | ||||
|                         </span> | ||||
| <?php if (Session::isLoggedIn()): ?>
 | ||||
|                         <a | ||||
|                             <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                             href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'mood')) ?>" | ||||
|                             <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                             href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'mood')) ?>" | ||||
|                             class="change-mood">Change mood</a> | ||||
| <?php endif ?>
 | ||||
|                     </dd> | ||||
| @ -48,7 +48,7 @@ | ||||
| <?php endif; ?>
 | ||||
|             </aside> | ||||
|             <main id="ticks"> | ||||
|                 <h1 class="site-description"><?= Util::escape_html($config->siteDescription) ?></h1>
 | ||||
|                 <h1 class="site-description"><?= Util::escape_html($settings->siteDescription) ?></h1>
 | ||||
|                 <?php echo $tickList ?>
 | ||||
|             </main> | ||||
|         </div> | ||||
|  | ||||
| @ -1,8 +1,8 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var SettingsModel $settings */ ?>
 | ||||
| <?php /** @var string $csrf_token */ ?>
 | ||||
| <?php /** @var string $error */ ?>
 | ||||
|     <h2>Login</h2> | ||||
|     <form method="post" action="<?= Util::buildRelativeUrl($config->basePath, 'login') ?>"> | ||||
|     <form method="post" action="<?= Util::buildRelativeUrl($settings->basePath, 'login') ?>"> | ||||
|         <div class="fieldset-items"> | ||||
|             <input type="hidden" name="csrf_token" value="<?= Util::escape_html($csrf_token) ?>"> | ||||
|             <label for="username">Username:</label> | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var SettingsModel $settings */ ?>
 | ||||
| <?php /** @var array $logEntries */ ?>
 | ||||
| <?php /** @var array $availableRoutes */ ?>
 | ||||
| <?php /** @var array $availableLevels */ ?>
 | ||||
| @ -8,7 +8,7 @@ | ||||
|         <main> | ||||
|             <!-- Filters --> | ||||
|             <div class="log-filters"> | ||||
|                 <form method="get" action="<?= Util::buildRelativeUrl($config->basePath, 'admin/logs') ?>"> | ||||
|                 <form method="get" action="<?= Util::buildRelativeUrl($settings->basePath, 'admin/logs') ?>"> | ||||
|                     <fieldset> | ||||
|                         <legend>Filter Logs</legend> | ||||
|                         <div class="fieldset-items"> | ||||
| @ -35,7 +35,7 @@ | ||||
|                             </select> | ||||
| 
 | ||||
|                             <div></div><button type="submit">Filter</button> | ||||
|                             <div></div><a href="<?= Util::buildRelativeUrl($config->basePath, 'admin/logs') ?>">Clear</a> | ||||
|                             <div></div><a href="<?= Util::buildRelativeUrl($settings->basePath, 'admin/logs') ?>">Clear</a> | ||||
|                         </div> | ||||
|                     </fieldset> | ||||
|                 </form> | ||||
|  | ||||
| @ -1,35 +1,35 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var SettingsModel $settings */ ?>
 | ||||
| <?php /* https://www.w3schools.com/howto/howto_css_dropdown.asp */ ?>
 | ||||
|         <nav aria-label="Main navigation"> | ||||
|             <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath)) ?>">home</a> | ||||
|             <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath)) ?>">home</a> | ||||
|             <details> | ||||
|                 <summary aria-haspopup="true">feeds</summary> | ||||
|                 <div class="dropdown-items"> | ||||
|                     <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'feed/rss')) ?>">rss</a> | ||||
|                     <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'feed/atom')) ?>">atom</a> | ||||
|                     <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'feed/rss')) ?>">rss</a> | ||||
|                     <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'feed/atom')) ?>">atom</a> | ||||
|                 </div> | ||||
|             </details> | ||||
| <?php if (!Session::isLoggedIn()): ?>
 | ||||
|             <a tabindex="0" | ||||
|                href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'login')) ?>">login</a> | ||||
|                href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'login')) ?>">login</a> | ||||
| <?php else: ?>
 | ||||
|             <details> | ||||
|                 <summary aria-haspopup="true">admin</summary> | ||||
|                 <div class="dropdown-items"> | ||||
|                     <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin')) ?>">settings</a> | ||||
|                     <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/css')) ?>">css</a> | ||||
|                     <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/emoji')) ?>">emoji</a> | ||||
|                     <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/logs')) ?>">logs</a> | ||||
|                     <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'admin')) ?>">settings</a> | ||||
|                     <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'admin/css')) ?>">css</a> | ||||
|                     <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'admin/emoji')) ?>">emoji</a> | ||||
|                     <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'admin/logs')) ?>">logs</a> | ||||
|                 </div> | ||||
|             </details> | ||||
|             <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'logout')) ?>">logout</a> | ||||
|             <a <?php if($settings->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                href="<?= Util::escape_html(Util::buildRelativeUrl($settings->basePath, 'logout')) ?>">logout</a> | ||||
| <?php endif; ?>
 | ||||
|         </nav> | ||||
| @ -7,32 +7,32 @@ use PHPUnit\Framework\TestCase; | ||||
| class AdminControllerTest extends TestCase | ||||
| { | ||||
|     private PDO $mockPdo; | ||||
|     private ConfigModel $config; | ||||
|     private SettingsModel $settings; | ||||
|     private UserModel $user; | ||||
| 
 | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         // Create mock PDO
 | ||||
|         $this->mockPdo = $this->createMock(PDO::class); | ||||
|          | ||||
| 
 | ||||
|         // Create real config and user objects with mocked PDO
 | ||||
|         $this->config = new ConfigModel($this->mockPdo); | ||||
|         $this->config->siteTitle = 'Test Site'; | ||||
|         $this->config->siteDescription = 'Test Description'; | ||||
|         $this->config->baseUrl = 'https://example.com'; | ||||
|         $this->config->basePath = '/tkr'; | ||||
|         $this->config->itemsPerPage = 10; | ||||
|          | ||||
|         $this->settings = new SettingsModel($this->mockPdo); | ||||
|         $this->settings->siteTitle = 'Test Site'; | ||||
|         $this->settings->siteDescription = 'Test Description'; | ||||
|         $this->settings->baseUrl = 'https://example.com'; | ||||
|         $this->settings->basePath = '/tkr'; | ||||
|         $this->settings->itemsPerPage = 10; | ||||
| 
 | ||||
|         $this->user = new UserModel($this->mockPdo); | ||||
|         $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, | ||||
|             'settings' => $this->settings, | ||||
|             'user' => $this->user, | ||||
|         ]; | ||||
|     } | ||||
| @ -41,14 +41,14 @@ class AdminControllerTest extends TestCase | ||||
|     { | ||||
|         $controller = new AdminController(); | ||||
|         $data = $controller->getAdminData(false); | ||||
|          | ||||
| 
 | ||||
|         // Should return proper structure
 | ||||
|         $this->assertArrayHasKey('config', $data); | ||||
|         $this->assertArrayHasKey('settings', $data); | ||||
|         $this->assertArrayHasKey('user', $data); | ||||
|         $this->assertArrayHasKey('isSetup', $data); | ||||
|          | ||||
| 
 | ||||
|         // Should be the injected instances
 | ||||
|         $this->assertSame($this->config, $data['config']); | ||||
|         $this->assertSame($this->settings, $data['settings']); | ||||
|         $this->assertSame($this->user, $data['user']); | ||||
|         $this->assertFalse($data['isSetup']); | ||||
|     } | ||||
| @ -57,14 +57,14 @@ class AdminControllerTest extends TestCase | ||||
|     { | ||||
|         $controller = new AdminController(); | ||||
|         $data = $controller->getAdminData(true); | ||||
|          | ||||
| 
 | ||||
|         // Should return proper structure
 | ||||
|         $this->assertArrayHasKey('config', $data); | ||||
|         $this->assertArrayHasKey('settings', $data); | ||||
|         $this->assertArrayHasKey('user', $data); | ||||
|         $this->assertArrayHasKey('isSetup', $data); | ||||
|          | ||||
| 
 | ||||
|         // Should be the injected instances
 | ||||
|         $this->assertSame($this->config, $data['config']); | ||||
|         $this->assertSame($this->settings, $data['settings']); | ||||
|         $this->assertSame($this->user, $data['user']); | ||||
|         $this->assertTrue($data['isSetup']); | ||||
|     } | ||||
| @ -73,7 +73,7 @@ class AdminControllerTest extends TestCase | ||||
|     { | ||||
|         $controller = new AdminController(); | ||||
|         $result = $controller->saveSettings([], false); | ||||
|          | ||||
| 
 | ||||
|         $this->assertFalse($result['success']); | ||||
|         $this->assertContains('No data provided', $result['errors']); | ||||
|     } | ||||
| @ -81,7 +81,7 @@ class AdminControllerTest extends TestCase | ||||
|     public function testProcessSettingsSaveValidationErrors(): void | ||||
|     { | ||||
|         $controller = new AdminController(); | ||||
|          | ||||
| 
 | ||||
|         // Test data with multiple validation errors
 | ||||
|         $postData = [ | ||||
|             'username' => '',  // Missing username
 | ||||
| @ -94,12 +94,12 @@ class AdminControllerTest extends TestCase | ||||
|             'password' => 'test123', | ||||
|             'confirm_password' => 'different'  // Passwords don't match
 | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->saveSettings($postData, false); | ||||
|          | ||||
| 
 | ||||
|         $this->assertFalse($result['success']); | ||||
|         $this->assertNotEmpty($result['errors']); | ||||
|          | ||||
| 
 | ||||
|         // Should have multiple validation errors
 | ||||
|         $this->assertGreaterThan(5, count($result['errors'])); | ||||
|     } | ||||
| @ -133,16 +133,16 @@ class AdminControllerTest extends TestCase | ||||
|         $this->mockPdo->method('query')->willReturn($mockStatement); | ||||
| 
 | ||||
|         // Create models with mocked PDO
 | ||||
|         $config = new ConfigModel($this->mockPdo); | ||||
|         $settings = new SettingsModel($this->mockPdo); | ||||
|         $user = new UserModel($this->mockPdo); | ||||
|          | ||||
| 
 | ||||
|         // Update global $app with test models
 | ||||
|         global $app; | ||||
|         $app['config'] = $config; | ||||
|         $app['settings'] = $settings; | ||||
|         $app['user'] = $user; | ||||
|          | ||||
| 
 | ||||
|         $controller = new AdminController(); | ||||
|          | ||||
| 
 | ||||
|         $postData = [ | ||||
|             'username' => 'newuser', | ||||
|             'display_name' => 'New User', | ||||
| @ -155,9 +155,9 @@ class AdminControllerTest extends TestCase | ||||
|             'strict_accessibility' => 'on', | ||||
|             'log_level' => 2 | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->saveSettings($postData, false); | ||||
|          | ||||
| 
 | ||||
|         $this->assertTrue($result['success']); | ||||
|         $this->assertEmpty($result['errors']); | ||||
|     } | ||||
| @ -191,20 +191,20 @@ class AdminControllerTest extends TestCase | ||||
|         $this->mockPdo->expects($this->atLeastOnce()) | ||||
|                      ->method('prepare') | ||||
|                      ->willReturn($mockStatement); | ||||
|          | ||||
| 
 | ||||
|         $this->mockPdo->method('query')->willReturn($mockStatement); | ||||
| 
 | ||||
|         // Create models with mocked PDO
 | ||||
|         $config = new ConfigModel($this->mockPdo); | ||||
|         $settings = new SettingsModel($this->mockPdo); | ||||
|         $user = new UserModel($this->mockPdo); | ||||
|          | ||||
| 
 | ||||
|         // Update global $app with test models
 | ||||
|         global $app; | ||||
|         $app['config'] = $config; | ||||
|         $app['settings'] = $settings; | ||||
|         $app['user'] = $user; | ||||
|          | ||||
| 
 | ||||
|         $controller = new AdminController(); | ||||
|          | ||||
| 
 | ||||
|         $postData = [ | ||||
|             'username' => 'testuser', | ||||
|             'display_name' => 'Test User', | ||||
| @ -216,9 +216,9 @@ class AdminControllerTest extends TestCase | ||||
|             'password' => 'newpassword', | ||||
|             'confirm_password' => 'newpassword' | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->saveSettings($postData, false); | ||||
|          | ||||
| 
 | ||||
|         $this->assertTrue($result['success']); | ||||
|     } | ||||
| 
 | ||||
| @ -228,16 +228,16 @@ class AdminControllerTest extends TestCase | ||||
|         $this->mockPdo->method('query') | ||||
|                      ->willThrowException(new PDOException("Database error")); | ||||
| 
 | ||||
|         $config = new ConfigModel($this->mockPdo); | ||||
|         $settings = new SettingsModel($this->mockPdo); | ||||
|         $user = new UserModel($this->mockPdo); | ||||
|          | ||||
| 
 | ||||
|         // Update global $app with test models
 | ||||
|         global $app; | ||||
|         $app['config'] = $config; | ||||
|         $app['settings'] = $settings; | ||||
|         $app['user'] = $user; | ||||
|          | ||||
| 
 | ||||
|         $controller = new AdminController(); | ||||
|          | ||||
| 
 | ||||
|         $postData = [ | ||||
|             'username' => 'testuser', | ||||
|             'display_name' => 'Test User', | ||||
| @ -247,9 +247,9 @@ class AdminControllerTest extends TestCase | ||||
|             'base_path' => '/tkr', | ||||
|             'items_per_page' => 10 | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->saveSettings($postData, false); | ||||
|          | ||||
| 
 | ||||
|         $this->assertFalse($result['success']); | ||||
|         $this->assertContains('Failed to save settings', $result['errors']); | ||||
|     } | ||||
|  | ||||
| @ -7,7 +7,7 @@ class FeedControllerTest extends TestCase | ||||
| { | ||||
|     private PDO $mockPdo; | ||||
|     private PDOStatement $mockStatement; | ||||
|     private ConfigModel $mockConfig; | ||||
|     private SettingsModel $mockConfig; | ||||
|     private UserModel $mockUser; | ||||
|     private string $tempLogDir; | ||||
| 
 | ||||
| @ -17,31 +17,31 @@ class FeedControllerTest 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'); | ||||
|          | ||||
| 
 | ||||
|         // Create mock PDO and PDOStatement
 | ||||
|         $this->mockStatement = $this->createMock(PDOStatement::class); | ||||
|         $this->mockPdo = $this->createMock(PDO::class); | ||||
|          | ||||
| 
 | ||||
|         // Mock config with feed-relevant properties
 | ||||
|         $this->mockConfig = new ConfigModel($this->mockPdo); | ||||
|         $this->mockConfig = new SettingsModel($this->mockPdo); | ||||
|         $this->mockConfig->itemsPerPage = 10; | ||||
|         $this->mockConfig->basePath = '/tkr'; | ||||
|         $this->mockConfig->siteTitle = 'Test Site'; | ||||
|         $this->mockConfig->siteDescription = 'Test Description'; | ||||
|         $this->mockConfig->baseUrl = 'https://test.example.com'; | ||||
|          | ||||
| 
 | ||||
|         // 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, | ||||
|             'settings' => $this->mockConfig, | ||||
|             'user' => $this->mockUser, | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         // Set log level on config for Log class
 | ||||
|         $this->mockConfig->logLevel = 1; // Allow DEBUG level logs
 | ||||
|     } | ||||
| @ -57,7 +57,7 @@ class FeedControllerTest extends TestCase | ||||
|     private function deleteDirectory(string $dir): void | ||||
|     { | ||||
|         if (!is_dir($dir)) return; | ||||
|          | ||||
| 
 | ||||
|         $files = array_diff(scandir($dir), ['.', '..']); | ||||
|         foreach ($files as $file) { | ||||
|             $path = $dir . '/' . $file; | ||||
| @ -71,11 +71,11 @@ class FeedControllerTest extends TestCase | ||||
|         // Mock PDO prepare method to return our mock statement
 | ||||
|         $this->mockPdo->method('prepare') | ||||
|                       ->willReturn($this->mockStatement); | ||||
|          | ||||
| 
 | ||||
|         // Mock statement execute method
 | ||||
|         $this->mockStatement->method('execute') | ||||
|                            ->willReturn(true); | ||||
|          | ||||
| 
 | ||||
|         // Mock statement fetchAll to return our test data
 | ||||
|         $this->mockStatement->method('fetchAll') | ||||
|                            ->willReturn($tickData); | ||||
| @ -84,16 +84,16 @@ class FeedControllerTest extends TestCase | ||||
|     public function testControllerInstantiationWithNoTicks(): void | ||||
|     { | ||||
|         $this->setupMockDatabase([]); | ||||
|          | ||||
| 
 | ||||
|         $controller = new FeedController(); | ||||
|          | ||||
| 
 | ||||
|         // Verify it was created successfully
 | ||||
|         $this->assertInstanceOf(FeedController::class, $controller); | ||||
|          | ||||
| 
 | ||||
|         // Check logs
 | ||||
|         $logFile = $this->tempLogDir . '/logs/tkr.log'; | ||||
|         $this->assertFileExists($logFile); | ||||
|          | ||||
| 
 | ||||
|         $logContent = file_get_contents($logFile); | ||||
|         $this->assertStringContainsString('Loaded 0 ticks for feeds', $logContent); | ||||
|     } | ||||
| @ -104,18 +104,18 @@ class FeedControllerTest extends TestCase | ||||
|             ['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'First tick'], | ||||
|             ['id' => 2, 'timestamp' => '2025-01-31 13:00:00', 'tick' => 'Second tick'], | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         $this->setupMockDatabase($testTicks); | ||||
|          | ||||
| 
 | ||||
|         $controller = new FeedController(); | ||||
|          | ||||
| 
 | ||||
|         // Verify it was created successfully
 | ||||
|         $this->assertInstanceOf(FeedController::class, $controller); | ||||
|          | ||||
| 
 | ||||
|         // Check logs
 | ||||
|         $logFile = $this->tempLogDir . '/logs/tkr.log'; | ||||
|         $this->assertFileExists($logFile); | ||||
|          | ||||
| 
 | ||||
|         $logContent = file_get_contents($logFile); | ||||
|         $this->assertStringContainsString('Loaded 2 ticks for feeds', $logContent); | ||||
|     } | ||||
| @ -123,18 +123,18 @@ class FeedControllerTest extends TestCase | ||||
|     public function testControllerCallsDatabaseCorrectly(): void | ||||
|     { | ||||
|         $this->setupMockDatabase([]); | ||||
|          | ||||
| 
 | ||||
|         // Verify that PDO prepare is called with the correct SQL for tick loading
 | ||||
|         $this->mockPdo->expects($this->once()) | ||||
|                      ->method('prepare') | ||||
|                      ->with('SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?') | ||||
|                      ->willReturn($this->mockStatement); | ||||
|          | ||||
| 
 | ||||
|         // Verify that execute is called with correct parameters (page 1, offset 0)
 | ||||
|         $this->mockStatement->expects($this->once()) | ||||
|                            ->method('execute') | ||||
|                            ->with([10, 0]); // itemsPerPage=10, page 1 = offset 0
 | ||||
|          | ||||
| 
 | ||||
|         new FeedController(); | ||||
|     } | ||||
| 
 | ||||
| @ -143,16 +143,16 @@ class FeedControllerTest extends TestCase | ||||
|         $testTicks = [ | ||||
|             ['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'Test tick'] | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         $this->setupMockDatabase($testTicks); | ||||
|          | ||||
| 
 | ||||
|         $controller = new FeedController(); | ||||
|          | ||||
| 
 | ||||
|         // Capture output to prevent headers/content from affecting test
 | ||||
|         ob_start(); | ||||
|         $controller->rss(); | ||||
|         ob_end_clean(); | ||||
|          | ||||
| 
 | ||||
|         // Check logs for RSS generation
 | ||||
|         $logFile = $this->tempLogDir . '/logs/tkr.log'; | ||||
|         $logContent = file_get_contents($logFile); | ||||
| @ -165,16 +165,16 @@ class FeedControllerTest extends TestCase | ||||
|             ['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'Test tick'], | ||||
|             ['id' => 2, 'timestamp' => '2025-01-31 13:00:00', 'tick' => 'Another tick'] | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         $this->setupMockDatabase($testTicks); | ||||
|          | ||||
| 
 | ||||
|         $controller = new FeedController(); | ||||
|          | ||||
| 
 | ||||
|         // Capture output to prevent headers/content from affecting test
 | ||||
|         ob_start(); | ||||
|         $controller->atom(); | ||||
|         ob_end_clean(); | ||||
|          | ||||
| 
 | ||||
|         // Check logs for Atom generation
 | ||||
|         $logFile = $this->tempLogDir . '/logs/tkr.log'; | ||||
|         $logContent = file_get_contents($logFile); | ||||
|  | ||||
| @ -7,33 +7,33 @@ class HomeControllerTest extends TestCase | ||||
| { | ||||
|     private PDO $mockPdo; | ||||
|     private PDOStatement $mockStatement; | ||||
|     private ConfigModel $mockConfig; | ||||
|     private SettingsModel $mockConfig; | ||||
|     private UserModel $mockUser; | ||||
| 
 | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         // Reset Log state to prevent test pollution
 | ||||
|         Log::init(sys_get_temp_dir() . '/tkr_controller_test.log'); | ||||
|          | ||||
| 
 | ||||
|         // Create mock PDO and PDOStatement
 | ||||
|         $this->mockStatement = $this->createMock(PDOStatement::class); | ||||
|         $this->mockPdo = $this->createMock(PDO::class); | ||||
|          | ||||
| 
 | ||||
|         // Mock config
 | ||||
|         $this->mockConfig = new ConfigModel($this->mockPdo); | ||||
|         $this->mockConfig = new SettingsModel($this->mockPdo); | ||||
|         $this->mockConfig->itemsPerPage = 10; | ||||
|         $this->mockConfig->basePath = '/tkr'; | ||||
|          | ||||
| 
 | ||||
|         // Mock user
 | ||||
|         $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, | ||||
|             'settings' => $this->mockConfig, | ||||
|             'user' => $this->mockUser, | ||||
|         ]; | ||||
|     } | ||||
| @ -43,11 +43,11 @@ class HomeControllerTest extends TestCase | ||||
|         // Mock PDO prepare method to return our mock statement
 | ||||
|         $this->mockPdo->method('prepare') | ||||
|                       ->willReturn($this->mockStatement); | ||||
|          | ||||
| 
 | ||||
|         // Mock statement execute method
 | ||||
|         $this->mockStatement->method('execute') | ||||
|                            ->willReturn(true); | ||||
|          | ||||
| 
 | ||||
|         // Mock statement fetchAll to return our test data
 | ||||
|         $this->mockStatement->method('fetchAll') | ||||
|                            ->willReturn($tickData); | ||||
| @ -59,7 +59,7 @@ class HomeControllerTest extends TestCase | ||||
|             // Mock successful insert
 | ||||
|             $this->mockPdo->method('prepare') | ||||
|                           ->willReturn($this->mockStatement); | ||||
|              | ||||
| 
 | ||||
|             $this->mockStatement->method('execute') | ||||
|                                ->willReturn(true); | ||||
|         } else { | ||||
| @ -72,19 +72,19 @@ class HomeControllerTest extends TestCase | ||||
|     public function testGetHomeDataWithNoTicks(): void | ||||
|     { | ||||
|         $this->setupMockDatabase([]); // Empty array = no ticks
 | ||||
|          | ||||
| 
 | ||||
|         $controller = new HomeController(); | ||||
|         $data = $controller->getHomeData(1); | ||||
|          | ||||
| 
 | ||||
|         // Should return proper structure
 | ||||
|         $this->assertArrayHasKey('config', $data); | ||||
|         $this->assertArrayHasKey('settings', $data); | ||||
|         $this->assertArrayHasKey('user', $data); | ||||
|         $this->assertArrayHasKey('tickList', $data); | ||||
|          | ||||
| 
 | ||||
|         // Config and user should be the injected instances
 | ||||
|         $this->assertSame($this->mockConfig, $data['config']); | ||||
|         $this->assertSame($this->mockConfig, $data['settings']); | ||||
|         $this->assertSame($this->mockUser, $data['user']); | ||||
|          | ||||
| 
 | ||||
|         // Should have tick list HTML (even if empty)
 | ||||
|         $this->assertIsString($data['tickList']); | ||||
|     } | ||||
| @ -97,17 +97,17 @@ class HomeControllerTest extends TestCase | ||||
|             ['id' => 2, 'timestamp' => '2025-01-31 13:00:00', 'tick' => 'Second tick'], | ||||
|             ['id' => 3, 'timestamp' => '2025-01-31 14:00:00', 'tick' => 'Third tick'], | ||||
|         ]; | ||||
|          | ||||
| 
 | ||||
|         $this->setupMockDatabase($testTicks); | ||||
|          | ||||
| 
 | ||||
|         $controller = new HomeController(); | ||||
|         $data = $controller->getHomeData(1); | ||||
|          | ||||
| 
 | ||||
|         // Should return proper structure
 | ||||
|         $this->assertArrayHasKey('config', $data); | ||||
|         $this->assertArrayHasKey('settings', $data); | ||||
|         $this->assertArrayHasKey('user', $data); | ||||
|         $this->assertArrayHasKey('tickList', $data); | ||||
|          | ||||
| 
 | ||||
|         // Should contain tick content in HTML
 | ||||
|         $this->assertStringContainsString('First tick', $data['tickList']); | ||||
|         $this->assertStringContainsString('Second tick', $data['tickList']); | ||||
| @ -117,18 +117,18 @@ class HomeControllerTest extends TestCase | ||||
|     public function testGetHomeDataCallsDatabaseCorrectly(): void | ||||
|     { | ||||
|         $this->setupMockDatabase([]); | ||||
|          | ||||
| 
 | ||||
|         // Verify that PDO prepare is called with the correct SQL
 | ||||
|         $this->mockPdo->expects($this->once()) | ||||
|                      ->method('prepare') | ||||
|                      ->with('SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?') | ||||
|                      ->willReturn($this->mockStatement); | ||||
|          | ||||
| 
 | ||||
|         // Verify that execute is called with correct parameters for page 2
 | ||||
|         $this->mockStatement->expects($this->once()) | ||||
|                            ->method('execute') | ||||
|                            ->with([10, 10]); // itemsPerPage=10, page 2 = offset 10
 | ||||
|          | ||||
| 
 | ||||
|         $controller = new HomeController(); | ||||
|         $controller->getHomeData(2); // Page 2
 | ||||
|     } | ||||
| @ -136,28 +136,28 @@ class HomeControllerTest extends TestCase | ||||
|     public function testProcessTickSuccess(): void | ||||
|     { | ||||
|         $this->setupMockDatabaseForInsert(true); | ||||
|          | ||||
| 
 | ||||
|         // Verify the INSERT SQL is called correctly
 | ||||
|         $this->mockPdo->expects($this->once()) | ||||
|                      ->method('prepare') | ||||
|                      ->with('INSERT INTO tick(timestamp, tick) values (?, ?)') | ||||
|                      ->willReturn($this->mockStatement); | ||||
|          | ||||
| 
 | ||||
|         // Verify execute is called with timestamp and content
 | ||||
|         $this->mockStatement->expects($this->once()) | ||||
|                            ->method('execute') | ||||
|                            ->with($this->callback(function($params) { | ||||
|                                // First param should be a timestamp, second should be the tick content
 | ||||
|                                return count($params) === 2  | ||||
|                                    && is_string($params[0])  | ||||
|                                return count($params) === 2 | ||||
|                                    && is_string($params[0]) | ||||
|                                    && $params[1] === 'This is a test tick'; | ||||
|                            })); | ||||
|          | ||||
| 
 | ||||
|         $controller = new HomeController(); | ||||
|         $postData = ['new_tick' => 'This is a test tick']; | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->processTick($postData); | ||||
|          | ||||
| 
 | ||||
|         $this->assertTrue($result['success']); | ||||
|         $this->assertEquals('Tick saved successfully', $result['message']); | ||||
|     } | ||||
| @ -166,12 +166,12 @@ class HomeControllerTest extends TestCase | ||||
|     { | ||||
|         // PDO shouldn't be called at all for empty content
 | ||||
|         $this->mockPdo->expects($this->never())->method('prepare'); | ||||
|          | ||||
| 
 | ||||
|         $controller = new HomeController(); | ||||
|         $postData = ['new_tick' => '   '];  // Just whitespace
 | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->processTick($postData); | ||||
|          | ||||
| 
 | ||||
|         $this->assertFalse($result['success']); | ||||
|         $this->assertEquals('Empty tick ignored', $result['message']); | ||||
|     } | ||||
| @ -180,12 +180,12 @@ class HomeControllerTest extends TestCase | ||||
|     { | ||||
|         // PDO shouldn't be called at all for missing field
 | ||||
|         $this->mockPdo->expects($this->never())->method('prepare'); | ||||
|          | ||||
| 
 | ||||
|         $controller = new HomeController(); | ||||
|         $postData = [];  // No new_tick field
 | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->processTick($postData); | ||||
|          | ||||
| 
 | ||||
|         $this->assertFalse($result['success']); | ||||
|         $this->assertEquals('No tick content provided', $result['message']); | ||||
|     } | ||||
| @ -193,31 +193,31 @@ class HomeControllerTest extends TestCase | ||||
|     public function testProcessTickTrimsWhitespace(): void | ||||
|     { | ||||
|         $this->setupMockDatabaseForInsert(true); | ||||
|          | ||||
| 
 | ||||
|         // Verify execute is called with trimmed content
 | ||||
|         $this->mockStatement->expects($this->once()) | ||||
|                            ->method('execute') | ||||
|                            ->with($this->callback(function($params) { | ||||
|                                return $params[1] === 'This has whitespace'; // Should be trimmed
 | ||||
|                            })); | ||||
|          | ||||
| 
 | ||||
|         $controller = new HomeController(); | ||||
|         $postData = ['new_tick' => '  This has whitespace  ']; | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->processTick($postData); | ||||
|          | ||||
| 
 | ||||
|         $this->assertTrue($result['success']); | ||||
|     } | ||||
| 
 | ||||
|     public function testProcessTickHandlesDatabaseError(): void | ||||
|     { | ||||
|         $this->setupMockDatabaseForInsert(false); // Will throw exception
 | ||||
|          | ||||
| 
 | ||||
|         $controller = new HomeController(); | ||||
|         $postData = ['new_tick' => 'This will fail']; | ||||
|          | ||||
| 
 | ||||
|         $result = $controller->processTick($postData); | ||||
|          | ||||
| 
 | ||||
|         $this->assertFalse($result['success']); | ||||
|         $this->assertEquals('Failed to save tick', $result['message']); | ||||
|     } | ||||
|  | ||||
| @ -13,7 +13,7 @@ class LogControllerTest extends TestCase | ||||
|     { | ||||
|         $this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid(); | ||||
|         mkdir($this->tempLogDir, 0777, true); | ||||
|          | ||||
| 
 | ||||
|         $this->testLogFile = $this->tempLogDir . '/logs/tkr.log'; | ||||
|         mkdir(dirname($this->testLogFile), 0777, true); | ||||
| 
 | ||||
| @ -23,16 +23,16 @@ class LogControllerTest extends TestCase | ||||
| 
 | ||||
|         // Set up global $app for simplified dependency access
 | ||||
|         $mockPdo = $this->createMock(PDO::class); | ||||
|         $mockConfig = new ConfigModel($mockPdo); | ||||
|         $mockConfig = new SettingsModel($mockPdo); | ||||
|         $mockConfig->baseUrl = 'https://example.com'; | ||||
|         $mockConfig->basePath = '/tkr/'; | ||||
|          | ||||
| 
 | ||||
|         $mockUser = new UserModel($mockPdo); | ||||
|          | ||||
| 
 | ||||
|         global $app; | ||||
|         $app = [ | ||||
|             'db' => $mockPdo, | ||||
|             'config' => $mockConfig, | ||||
|             'settings' => $mockConfig, | ||||
|             'user' => $mockUser, | ||||
|         ]; | ||||
|     } | ||||
| @ -50,7 +50,7 @@ class LogControllerTest extends TestCase | ||||
|     private function deleteDirectory(string $dir): void | ||||
|     { | ||||
|         if (!is_dir($dir)) return; | ||||
|          | ||||
| 
 | ||||
|         $files = array_diff(scandir($dir), ['.', '..']); | ||||
|         foreach ($files as $file) { | ||||
|             $path = $dir . '/' . $file; | ||||
| @ -64,14 +64,14 @@ class LogControllerTest extends TestCase | ||||
|         // Uses global $app set up in setUp()
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
| 
 | ||||
|         // Should return empty log entries but valid structure
 | ||||
|         $this->assertArrayHasKey('logEntries', $data); | ||||
|         $this->assertArrayHasKey('availableRoutes', $data); | ||||
|         $this->assertArrayHasKey('availableLevels', $data); | ||||
|         $this->assertArrayHasKey('currentLevelFilter', $data); | ||||
|         $this->assertArrayHasKey('currentRouteFilter', $data); | ||||
|          | ||||
| 
 | ||||
|         $this->assertEmpty($data['logEntries']); | ||||
|         $this->assertEmpty($data['availableRoutes']); | ||||
|         $this->assertEquals(['DEBUG', 'INFO', 'WARNING', 'ERROR'], $data['availableLevels']); | ||||
| @ -96,15 +96,15 @@ class LogControllerTest extends TestCase | ||||
|         // Uses global $app set up in setUp()
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
| 
 | ||||
|         // Should parse all valid entries and ignore invalid ones
 | ||||
|         $this->assertCount(5, $data['logEntries']); | ||||
|          | ||||
| 
 | ||||
|         // Verify entries are in reverse chronological order (newest first)
 | ||||
|         $entries = $data['logEntries']; | ||||
|         $this->assertEquals('Info without route', $entries[0]['message']); | ||||
|         $this->assertEquals('Debug home page', $entries[4]['message']); | ||||
|          | ||||
| 
 | ||||
|         // Verify entry structure
 | ||||
|         $firstEntry = $entries[0]; | ||||
|         $this->assertArrayHasKey('timestamp', $firstEntry); | ||||
| @ -112,13 +112,13 @@ class LogControllerTest extends TestCase | ||||
|         $this->assertArrayHasKey('ip', $firstEntry); | ||||
|         $this->assertArrayHasKey('route', $firstEntry); | ||||
|         $this->assertArrayHasKey('message', $firstEntry); | ||||
|          | ||||
| 
 | ||||
|         // Test route extraction
 | ||||
|         $adminEntry = array_filter($entries, fn($e) => $e['message'] === 'Info admin page'); | ||||
|         $adminEntry = array_values($adminEntry)[0]; | ||||
|         $this->assertEquals('GET /admin', $adminEntry['route']); | ||||
|         $this->assertEquals('INFO', $adminEntry['level']); | ||||
|          | ||||
| 
 | ||||
|         // Test entry without route
 | ||||
|         $noRouteEntry = array_filter($entries, fn($e) => $e['message'] === 'Info without route'); | ||||
|         $noRouteEntry = array_values($noRouteEntry)[0]; | ||||
| @ -138,7 +138,7 @@ class LogControllerTest extends TestCase | ||||
|         // Uses global $app set up in setUp()
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData('ERROR'); | ||||
|          | ||||
| 
 | ||||
|         // Should only include ERROR entries
 | ||||
|         $this->assertCount(1, $data['logEntries']); | ||||
|         $this->assertEquals('ERROR', $data['logEntries'][0]['level']); | ||||
| @ -159,7 +159,7 @@ class LogControllerTest extends TestCase | ||||
|         // Uses global $app set up in setUp()
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData('', 'GET /admin'); | ||||
|          | ||||
| 
 | ||||
|         // Should only include GET /admin entries
 | ||||
|         $this->assertCount(1, $data['logEntries']); | ||||
|         $this->assertEquals('GET /admin', $data['logEntries'][0]['route']); | ||||
| @ -171,7 +171,7 @@ class LogControllerTest extends TestCase | ||||
|     { | ||||
|         $logContent = implode("\n", [ | ||||
|             '[2025-01-31 12:00:00] ERROR: 127.0.0.1 [GET /admin] - Admin error', | ||||
|             '[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin info',  | ||||
|             '[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin info', | ||||
|             '[2025-01-31 12:02:00] ERROR: 127.0.0.1 [GET /] - Home error' | ||||
|         ]); | ||||
| 
 | ||||
| @ -180,7 +180,7 @@ class LogControllerTest extends TestCase | ||||
|         // 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
 | ||||
|         $this->assertCount(1, $data['logEntries']); | ||||
|         $this->assertEquals('ERROR', $data['logEntries'][0]['level']); | ||||
| @ -204,7 +204,7 @@ class LogControllerTest extends TestCase | ||||
|         // Uses global $app set up in setUp()
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
| 
 | ||||
|         // Should read from all log files, newest first
 | ||||
|         $this->assertCount(3, $data['logEntries']); | ||||
|         $this->assertEquals('Current log entry', $data['logEntries'][0]['message']); | ||||
| @ -227,7 +227,7 @@ class LogControllerTest extends TestCase | ||||
|         // Uses global $app set up in setUp()
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
| 
 | ||||
|         // Should extract unique routes, sorted
 | ||||
|         $expectedRoutes = ['GET /', 'GET /admin', 'POST /admin']; | ||||
|         $this->assertEquals($expectedRoutes, $data['availableRoutes']); | ||||
| @ -247,7 +247,7 @@ class LogControllerTest extends TestCase | ||||
|         // Uses global $app set up in setUp()
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
| 
 | ||||
|         // Should only include valid entries, ignore invalid ones
 | ||||
|         $this->assertCount(2, $data['logEntries']); | ||||
|         $this->assertEquals('Another valid entry', $data['logEntries'][0]['message']); | ||||
|  | ||||
| @ -6,29 +6,29 @@ use PHPUnit\Framework\TestCase; | ||||
| class TickControllerTest extends TestCase | ||||
| { | ||||
|     private $mockPdo; | ||||
|     private $config; | ||||
|     private $settings; | ||||
|     private $user; | ||||
| 
 | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         // Reset Log state to prevent test pollution
 | ||||
|         Log::init(sys_get_temp_dir() . '/tkr_controller_test.log'); | ||||
|          | ||||
| 
 | ||||
|         // Set up mocks
 | ||||
|         $this->mockPdo = $this->createMock(PDO::class); | ||||
|          | ||||
|         $this->config = new ConfigModel($this->mockPdo); | ||||
|         $this->config->baseUrl = 'https://example.com'; | ||||
|         $this->config->basePath = '/tkr/'; | ||||
|         $this->config->itemsPerPage = 10; | ||||
|          | ||||
| 
 | ||||
|         $this->settings = new SettingsModel($this->mockPdo); | ||||
|         $this->settings->baseUrl = 'https://example.com'; | ||||
|         $this->settings->basePath = '/tkr/'; | ||||
|         $this->settings->itemsPerPage = 10; | ||||
| 
 | ||||
|         $this->user = new UserModel($this->mockPdo); | ||||
| 
 | ||||
|         // Set up global $app for simplified dependency access
 | ||||
|         global $app; | ||||
|         $app = [ | ||||
|             'db' => $this->mockPdo, | ||||
|             'config' => $this->config, | ||||
|             'settings' => $this->settings, | ||||
|             'user' => $this->user, | ||||
|         ]; | ||||
|     } | ||||
| @ -60,10 +60,10 @@ class TickControllerTest extends TestCase | ||||
| 
 | ||||
|         // Capture output since render() outputs directly
 | ||||
|         ob_start(); | ||||
|          | ||||
| 
 | ||||
|         $controller = new TickController(); | ||||
|         $controller->index(123); | ||||
|          | ||||
| 
 | ||||
|         $output = ob_get_clean(); | ||||
| 
 | ||||
|         // Should not be a 404 or 500 error
 | ||||
| @ -94,10 +94,10 @@ class TickControllerTest extends TestCase | ||||
| 
 | ||||
|         // Capture output
 | ||||
|         ob_start(); | ||||
|          | ||||
| 
 | ||||
|         $controller = new TickController(); | ||||
|         $controller->index(999); | ||||
|          | ||||
| 
 | ||||
|         $output = ob_get_clean(); | ||||
| 
 | ||||
|         // Should return 404 error
 | ||||
| @ -123,10 +123,10 @@ class TickControllerTest extends TestCase | ||||
| 
 | ||||
|         // Capture output
 | ||||
|         ob_start(); | ||||
|          | ||||
| 
 | ||||
|         $controller = new TickController(); | ||||
|         $controller->index(456); | ||||
|          | ||||
| 
 | ||||
|         $output = ob_get_clean(); | ||||
| 
 | ||||
|         // Should return 404 error for empty data
 | ||||
| @ -143,10 +143,10 @@ class TickControllerTest extends TestCase | ||||
| 
 | ||||
|         // Capture output
 | ||||
|         ob_start(); | ||||
|          | ||||
| 
 | ||||
|         $controller = new TickController(); | ||||
|         $controller->index(123); | ||||
|          | ||||
| 
 | ||||
|         $output = ob_get_clean(); | ||||
| 
 | ||||
|         // Should return 500 error
 | ||||
|  | ||||
| @ -7,12 +7,12 @@ class AtomGeneratorTest extends TestCase | ||||
| { | ||||
|     private function createMockConfig() { | ||||
|         $mockPdo = $this->createMock(PDO::class); | ||||
|         $config = new ConfigModel($mockPdo); | ||||
|         $config->siteTitle = 'Test Site'; | ||||
|         $config->siteDescription = 'Test Description'; | ||||
|         $config->baseUrl = 'https://example.com'; | ||||
|         $config->basePath = '/tkr/'; | ||||
|         return $config; | ||||
|         $settings = new SettingsModel($mockPdo); | ||||
|         $settings->siteTitle = 'Test Site'; | ||||
|         $settings->siteDescription = 'Test Description'; | ||||
|         $settings->baseUrl = 'https://example.com'; | ||||
|         $settings->basePath = '/tkr/'; | ||||
|         return $settings; | ||||
|     } | ||||
| 
 | ||||
|     private function createSampleTicks() { | ||||
| @ -23,10 +23,10 @@ class AtomGeneratorTest extends TestCase | ||||
|     } | ||||
| 
 | ||||
|     public function testCanGenerateValidAtom() { | ||||
|         $config = $this->createMockConfig(); | ||||
|         $settings = $this->createMockConfig(); | ||||
|         $ticks = $this->createSampleTicks(); | ||||
| 
 | ||||
|         $generator = new AtomGenerator($config, $ticks); | ||||
|         $generator = new AtomGenerator($settings, $ticks); | ||||
|         $xml = $generator->generate(); | ||||
| 
 | ||||
|         // Test XML structure
 | ||||
| @ -58,8 +58,8 @@ class AtomGeneratorTest extends TestCase | ||||
|     } | ||||
| 
 | ||||
|     public function testCanHandleEmptyTickList() { | ||||
|         $config = $this->createMockConfig(); | ||||
|         $generator = new AtomGenerator($config, []); | ||||
|         $settings = $this->createMockConfig(); | ||||
|         $generator = new AtomGenerator($settings, []); | ||||
|         $xml = $generator->generate(); | ||||
| 
 | ||||
|         // Should still be valid Atom with no entries
 | ||||
| @ -85,7 +85,7 @@ class AtomGeneratorTest extends TestCase | ||||
|     } | ||||
| 
 | ||||
|     public function testCanHandleSpecialCharactersAndUnicode() { | ||||
|         $config = $this->createMockConfig(); | ||||
|         $settings = $this->createMockConfig(); | ||||
| 
 | ||||
|         // Test various challenging characters
 | ||||
|         $ticks = [ | ||||
| @ -111,7 +111,7 @@ class AtomGeneratorTest extends TestCase | ||||
|             ] | ||||
|         ]; | ||||
| 
 | ||||
|         $generator = new AtomGenerator($config, $ticks); | ||||
|         $generator = new AtomGenerator($settings, $ticks); | ||||
|         $xml = $generator->generate(); | ||||
| 
 | ||||
|         // Test that emojis are preserved
 | ||||
|  | ||||
| @ -7,12 +7,12 @@ class FeedGeneratorTest extends TestCase | ||||
| { | ||||
|     private function createMockConfig() { | ||||
|         $mockPdo = $this->createMock(PDO::class); | ||||
|         $config = new ConfigModel($mockPdo); | ||||
|         $config->siteTitle = 'Test Site'; | ||||
|         $config->siteDescription = 'Test Description'; | ||||
|         $config->baseUrl = 'https://example.com'; | ||||
|         $config->basePath = '/tkr/'; | ||||
|         return $config; | ||||
|         $settings = new SettingsModel($mockPdo); | ||||
|         $settings->siteTitle = 'Test Site'; | ||||
|         $settings->siteDescription = 'Test Description'; | ||||
|         $settings->baseUrl = 'https://example.com'; | ||||
|         $settings->basePath = '/tkr/'; | ||||
|         return $settings; | ||||
|     } | ||||
| 
 | ||||
|     private function createSampleTicks() { | ||||
| @ -22,11 +22,11 @@ class FeedGeneratorTest extends TestCase | ||||
|         ]; | ||||
|     } | ||||
| 
 | ||||
|     private function createTestGenerator($config = null, $ticks = null) { | ||||
|         $config = $config ?? $this->createMockConfig(); | ||||
|     private function createTestGenerator($settings = null, $ticks = null) { | ||||
|         $settings = $settings ?? $this->createMockConfig(); | ||||
|         $ticks = $ticks ?? $this->createSampleTicks(); | ||||
| 
 | ||||
|         return new class($config, $ticks) extends FeedGenerator { | ||||
|         return new class($settings, $ticks) extends FeedGenerator { | ||||
|             public function generate(): string { | ||||
|                 return '<test>content</test>'; | ||||
|             } | ||||
| @ -69,12 +69,12 @@ class FeedGeneratorTest extends TestCase | ||||
| 
 | ||||
|     public function testUrlMethodsHandleSubdomainConfiguration() { | ||||
|         $mockPdo = $this->createMock(PDO::class); | ||||
|         $config = new ConfigModel($mockPdo); | ||||
|         $config->siteTitle = 'Test Site'; | ||||
|         $config->baseUrl = 'https://tkr.example.com'; | ||||
|         $config->basePath = '/'; | ||||
|         $settings = new SettingsModel($mockPdo); | ||||
|         $settings->siteTitle = 'Test Site'; | ||||
|         $settings->baseUrl = 'https://tkr.example.com'; | ||||
|         $settings->basePath = '/'; | ||||
| 
 | ||||
|         $generator = $this->createTestGenerator($config, []); | ||||
|         $generator = $this->createTestGenerator($settings, []); | ||||
| 
 | ||||
|         $this->assertEquals('https://tkr.example.com/', $generator->testGetSiteUrl()); | ||||
|         $this->assertEquals('https://tkr.example.com/tick/456', $generator->testBuildTickUrl(456)); | ||||
| @ -82,12 +82,12 @@ class FeedGeneratorTest extends TestCase | ||||
| 
 | ||||
|     public function testUrlMethodsHandleEmptyBasePath() { | ||||
|         $mockPdo = $this->createMock(PDO::class); | ||||
|         $config = new ConfigModel($mockPdo); | ||||
|         $config->siteTitle = 'Test Site'; | ||||
|         $config->baseUrl = 'https://example.com'; | ||||
|         $config->basePath = ''; | ||||
|         $settings = new SettingsModel($mockPdo); | ||||
|         $settings->siteTitle = 'Test Site'; | ||||
|         $settings->baseUrl = 'https://example.com'; | ||||
|         $settings->basePath = ''; | ||||
| 
 | ||||
|         $generator = $this->createTestGenerator($config, []); | ||||
|         $generator = $this->createTestGenerator($settings, []); | ||||
| 
 | ||||
|         $this->assertEquals('https://example.com/', $generator->testGetSiteUrl()); | ||||
|         $this->assertEquals('https://example.com/tick/789', $generator->testBuildTickUrl(789)); | ||||
| @ -106,12 +106,12 @@ class FeedGeneratorTest extends TestCase | ||||
| 
 | ||||
|         foreach ($testCases as [$basePath, $expectedSiteUrl, $expectedTickUrl]) { | ||||
|             $mockPdo = $this->createMock(PDO::class); | ||||
|             $config = new ConfigModel($mockPdo); | ||||
|             $config->siteTitle = 'Test Site'; | ||||
|             $config->baseUrl = 'https://example.com'; | ||||
|             $config->basePath = $basePath; | ||||
|             $settings = new SettingsModel($mockPdo); | ||||
|             $settings->siteTitle = 'Test Site'; | ||||
|             $settings->baseUrl = 'https://example.com'; | ||||
|             $settings->basePath = $basePath; | ||||
| 
 | ||||
|             $generator = $this->createTestGenerator($config, []); | ||||
|             $generator = $this->createTestGenerator($settings, []); | ||||
| 
 | ||||
|             $this->assertEquals($expectedSiteUrl, $generator->testGetSiteUrl(), "Failed for basePath: '$basePath'"); | ||||
|             $this->assertEquals($expectedTickUrl, $generator->testBuildTickUrl(123), "Failed for basePath: '$basePath'"); | ||||
|  | ||||
| @ -7,12 +7,12 @@ class RssGeneratorTest extends TestCase | ||||
| { | ||||
|     private function createMockConfig() { | ||||
|         $mockPdo = $this->createMock(PDO::class); | ||||
|         $config = new ConfigModel($mockPdo); | ||||
|         $config->siteTitle = 'Test Site'; | ||||
|         $config->siteDescription = 'Test Description'; | ||||
|         $config->baseUrl = 'https://example.com'; | ||||
|         $config->basePath = '/tkr/'; | ||||
|         return $config; | ||||
|         $settings = new SettingsModel($mockPdo); | ||||
|         $settings->siteTitle = 'Test Site'; | ||||
|         $settings->siteDescription = 'Test Description'; | ||||
|         $settings->baseUrl = 'https://example.com'; | ||||
|         $settings->basePath = '/tkr/'; | ||||
|         return $settings; | ||||
|     } | ||||
| 
 | ||||
|     private function createSampleTicks() { | ||||
| @ -23,10 +23,10 @@ class RssGeneratorTest extends TestCase | ||||
|     } | ||||
| 
 | ||||
|     public function testCanGenerateValidRss() { | ||||
|         $config = $this->createMockConfig(); | ||||
|         $settings = $this->createMockConfig(); | ||||
|         $ticks = $this->createSampleTicks(); | ||||
| 
 | ||||
|         $generator = new RssGenerator($config, $ticks); | ||||
|         $generator = new RssGenerator($settings, $ticks); | ||||
|         $xml = $generator->generate(); | ||||
| 
 | ||||
|         // Test XML structure
 | ||||
| @ -56,8 +56,8 @@ class RssGeneratorTest extends TestCase | ||||
|     } | ||||
| 
 | ||||
|     public function testCanHandleEmptyTickList() { | ||||
|         $config = $this->createMockConfig(); | ||||
|         $generator = new RssGenerator($config, []); | ||||
|         $settings = $this->createMockConfig(); | ||||
|         $generator = new RssGenerator($settings, []); | ||||
|         $xml = $generator->generate(); | ||||
| 
 | ||||
|         // Should still be valid RSS with no items
 | ||||
| @ -81,7 +81,7 @@ class RssGeneratorTest extends TestCase | ||||
|     } | ||||
| 
 | ||||
|     public function testCanHandleSpecialCharactersAndUnicode() { | ||||
|         $config = $this->createMockConfig(); | ||||
|         $settings = $this->createMockConfig(); | ||||
| 
 | ||||
|         // Test various challenging characters
 | ||||
|         $ticks = [ | ||||
| @ -107,7 +107,7 @@ class RssGeneratorTest extends TestCase | ||||
|             ] | ||||
|         ]; | ||||
| 
 | ||||
|         $generator = new RssGenerator($config, $ticks); | ||||
|         $generator = new RssGenerator($settings, $ticks); | ||||
|         $xml = $generator->generate(); | ||||
| 
 | ||||
|         // Test that emojis are preserved
 | ||||
|  | ||||
| @ -13,9 +13,9 @@ class LogTest extends TestCase | ||||
|         // Create a temporary directory for test logs
 | ||||
|         $this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid(); | ||||
|         mkdir($this->tempLogDir, 0777, true); | ||||
|          | ||||
| 
 | ||||
|         $this->testLogFile = $this->tempLogDir . '/tkr.log'; | ||||
|          | ||||
| 
 | ||||
|         // Initialize Log with test file and reset route context
 | ||||
|         Log::init($this->testLogFile); | ||||
|         Log::setRouteContext(''); | ||||
| @ -32,31 +32,31 @@ class LogTest extends TestCase | ||||
|     private function deleteDirectory(string $dir): void | ||||
|     { | ||||
|         if (!is_dir($dir)) return; | ||||
|          | ||||
| 
 | ||||
|         $iterator = new RecursiveIteratorIterator( | ||||
|             new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), | ||||
|             RecursiveIteratorIterator::CHILD_FIRST | ||||
|         ); | ||||
|          | ||||
| 
 | ||||
|         foreach ($iterator as $path) { | ||||
|             $path->isDir() ? rmdir($path->getRealPath()) : unlink($path->getRealPath()); | ||||
|         } | ||||
|         rmdir($dir); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     private function setLogLevel(int $level): void | ||||
|     { | ||||
|         global $app; | ||||
|         $app = ['config' => (object)['logLevel' => $level]]; | ||||
|         $app = ['settings' => (object)['logLevel' => $level]]; | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     private function assertLogContains(string $message): void | ||||
|     { | ||||
|         $this->assertFileExists($this->testLogFile); | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|         $this->assertStringContainsString($message, $logContent); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     private function assertLogDoesNotContain(string $message): void | ||||
|     { | ||||
|         $this->assertFileExists($this->testLogFile); | ||||
| @ -68,9 +68,9 @@ class LogTest extends TestCase | ||||
|     { | ||||
|         Log::setRouteContext('GET /admin'); | ||||
|         $this->setLogLevel(1); // DEBUG level
 | ||||
|          | ||||
| 
 | ||||
|         Log::debug('Test message'); | ||||
|          | ||||
| 
 | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|         $this->assertStringContainsString('[GET /admin]', $logContent); | ||||
|         $this->assertStringContainsString('Test message', $logContent); | ||||
| @ -80,11 +80,11 @@ class LogTest extends TestCase | ||||
|     { | ||||
|         Log::setRouteContext(''); | ||||
|         $this->setLogLevel(1); | ||||
|          | ||||
| 
 | ||||
|         Log::info('Test without route'); | ||||
|          | ||||
| 
 | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|          | ||||
| 
 | ||||
|         // Should match format without route context: [timestamp] LEVEL: IP - message
 | ||||
|         $this->assertMatchesRegularExpression( | ||||
|             '/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: .+ - Test without route/', | ||||
| @ -95,12 +95,12 @@ class LogTest extends TestCase | ||||
|     public function testLogLevelFiltering(): void | ||||
|     { | ||||
|         $this->setLogLevel(3); // WARNING level
 | ||||
|          | ||||
| 
 | ||||
|         Log::debug('Debug message');   // Should be filtered out
 | ||||
|         Log::info('Info message');     // Should be filtered out  
 | ||||
|         Log::info('Info message');     // Should be filtered out
 | ||||
|         Log::warning('Warning message'); // Should be logged
 | ||||
|         Log::error('Error message');   // Should be logged
 | ||||
|          | ||||
| 
 | ||||
|         $this->assertLogDoesNotContain('Debug message'); | ||||
|         $this->assertLogDoesNotContain('Info message'); | ||||
|         $this->assertLogContains('Warning message'); | ||||
| @ -111,11 +111,11 @@ class LogTest extends TestCase | ||||
|     { | ||||
|         Log::setRouteContext('POST /admin'); | ||||
|         $this->setLogLevel(1); | ||||
|          | ||||
| 
 | ||||
|         Log::error('Test error message'); | ||||
|          | ||||
| 
 | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|          | ||||
| 
 | ||||
|         // Check log format: [timestamp] LEVEL: IP [route] - message
 | ||||
|         $this->assertMatchesRegularExpression( | ||||
|             '/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] ERROR: .+ \[POST \/admin\] - Test error message/', | ||||
| @ -126,15 +126,15 @@ class LogTest extends TestCase | ||||
|     public function testInitCreatesLogDirectory(): void | ||||
|     { | ||||
|         $newLogFile = $this->tempLogDir . '/nested/logs/test.log'; | ||||
|          | ||||
| 
 | ||||
|         // Directory doesn't exist yet
 | ||||
|         $this->assertDirectoryDoesNotExist(dirname($newLogFile)); | ||||
|          | ||||
| 
 | ||||
|         Log::init($newLogFile); | ||||
|          | ||||
| 
 | ||||
|         // init() should create the directory
 | ||||
|         $this->assertDirectoryExists(dirname($newLogFile)); | ||||
|          | ||||
| 
 | ||||
|         // Verify we can actually write to it
 | ||||
|         $this->setLogLevel(1); | ||||
|         Log::info('Test directory creation'); | ||||
| @ -144,35 +144,35 @@ class LogTest extends TestCase | ||||
|     public function testLogRotation(): void | ||||
|     { | ||||
|         $this->setLogLevel(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); | ||||
|         file_put_contents($this->testLogFile, $logLines); | ||||
|          | ||||
| 
 | ||||
|         // This should trigger rotation
 | ||||
|         Log::info('This should trigger rotation'); | ||||
|          | ||||
| 
 | ||||
|         // Verify rotation happened
 | ||||
|         $this->assertFileExists($this->testLogFile . '.1'); | ||||
|         $this->assertLogContains('This should trigger rotation'); | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     public function testLogRotationLimitsFileCount(): void | ||||
|     { | ||||
|         $this->setLogLevel(1); | ||||
|          | ||||
| 
 | ||||
|         // Create 5 existing rotated log files (.1 through .5)
 | ||||
|         for ($i = 1; $i <= 5; $i++) { | ||||
|             file_put_contents($this->testLogFile . '.' . $i, "Old log file $i\n"); | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         // Create main log file at rotation threshold
 | ||||
|         $logLines = str_repeat("[2025-01-31 12:00:00] INFO: 127.0.0.1 - Test line\n", 1000); | ||||
|         file_put_contents($this->testLogFile, $logLines); | ||||
|          | ||||
| 
 | ||||
|         // This should trigger rotation and delete the oldest file (.5)
 | ||||
|         Log::info('Trigger rotation with max files'); | ||||
|          | ||||
| 
 | ||||
|         // Verify rotation happened and file count is limited
 | ||||
|         $this->assertFileExists($this->testLogFile . '.1'); // New rotated file
 | ||||
|         $this->assertFileExists($this->testLogFile . '.2'); // Old .1 became .2
 | ||||
| @ -180,7 +180,7 @@ class LogTest extends TestCase | ||||
|         $this->assertFileExists($this->testLogFile . '.4'); // Old .3 became .4
 | ||||
|         $this->assertFileExists($this->testLogFile . '.5'); // Old .4 became .5
 | ||||
|         $this->assertFileDoesNotExist($this->testLogFile . '.6'); // Old .5 was deleted
 | ||||
|          | ||||
| 
 | ||||
|         $this->assertLogContains('Trigger rotation with max files'); | ||||
|     } | ||||
| 
 | ||||
| @ -188,12 +188,12 @@ class LogTest extends TestCase | ||||
|     { | ||||
|         // Set up config without logLevel property (simulates missing config value)
 | ||||
|         global $app; | ||||
|         $app = ['config' => (object)[]]; | ||||
|          | ||||
|         $app = ['settings' => (object)[]]; | ||||
| 
 | ||||
|         // Should not throw errors and should default to INFO level
 | ||||
|         Log::debug('Debug message');  // Should be filtered out (default INFO level = 2)
 | ||||
|         Log::info('Info message');    // Should be logged
 | ||||
|          | ||||
| 
 | ||||
|         $this->assertLogDoesNotContain('Debug message'); | ||||
|         $this->assertLogContains('Info message'); | ||||
|     } | ||||
|  | ||||
| @ -151,7 +151,7 @@ final class UtilTest extends TestCase | ||||
|         // Set up global $app with config
 | ||||
|         global $app; | ||||
|         $app = [ | ||||
|             'config' => (object)['strictAccessibility' => $strictAccessibility] | ||||
|             'settings' => (object)['strictAccessibility' => $strictAccessibility] | ||||
|         ]; | ||||
| 
 | ||||
|         $result = Util::linkify($input); | ||||
| @ -162,12 +162,12 @@ final class UtilTest extends TestCase | ||||
|         // Test linkify without new window
 | ||||
|         global $app; | ||||
|         $app = [ | ||||
|             'config' => (object)['strictAccessibility' => false] | ||||
|             'settings' => (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); | ||||
|     } | ||||
| @ -176,7 +176,7 @@ final class UtilTest extends TestCase | ||||
|         // 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); | ||||
|     } | ||||
| @ -187,7 +187,7 @@ final class UtilTest extends TestCase | ||||
|         $_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
 | ||||
|     } | ||||
| @ -195,7 +195,7 @@ final class UtilTest extends TestCase | ||||
|     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); | ||||
|     } | ||||
|  | ||||
| @ -175,11 +175,11 @@ try { | ||||
|     echo "💾 Saving configuration...\n"; | ||||
| 
 | ||||
|     // Create/update settings
 | ||||
|     $configModel = new ConfigModel($db); | ||||
|     $configModel->siteTitle = $siteTitle; | ||||
|     $configModel->baseUrl = $baseUrl; | ||||
|     $configModel->basePath = $basePath; | ||||
|     $config = $configModel->save(); | ||||
|     $settingsModel = new SettingsModel($db); | ||||
|     $settingsModel->siteTitle = $siteTitle; | ||||
|     $settingsModel->baseUrl = $baseUrl; | ||||
|     $settingsModel->basePath = $basePath; | ||||
|     $settings = $settingsModel->save(); | ||||
| 
 | ||||
|     // Create admin user
 | ||||
|     $userModel = new UserModel($db); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user