make-homepage-testable (#42)
Some checks are pending
Run unit tests / run-unit-tests (push) Waiting to run
Some checks are pending
Run unit tests / run-unit-tests (push) Waiting to run
Add logging and tests for the homepage and settings page. Make both support dependency injection. Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/42 Co-authored-by: Greg Sarjeant <greg@subcultureofone.org> Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
parent
879bd9ff9f
commit
9593a43cc0
@ -52,8 +52,10 @@ $db = Database::get();
|
||||
global $config;
|
||||
global $user;
|
||||
|
||||
$config = ConfigModel::load();
|
||||
$user = UserModel::load();
|
||||
$config = new ConfigModel($db);
|
||||
$config = $config->loadFromDatabase();
|
||||
$user = new UserModel($db);
|
||||
$user = $user->loadFromDatabase();
|
||||
|
||||
// Start a session and generate a CSRF Token
|
||||
// if there isn't already an active session
|
||||
@ -97,7 +99,8 @@ if ($method === 'POST' && $path != 'setup') {
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
// Render the requested route or throw a 404
|
||||
if (!Router::route($path, $method)){
|
||||
$router = new Router($db, $config, $user);
|
||||
if (!$router->route($path, $method)){
|
||||
http_response_code(404);
|
||||
echo "404 - Page Not Found";
|
||||
exit;
|
||||
|
@ -3,147 +3,167 @@ class AdminController extends Controller {
|
||||
// GET handler
|
||||
// render the admin page
|
||||
public function index(){
|
||||
global $config;
|
||||
global $user;
|
||||
|
||||
$vars = [
|
||||
'user' => $user,
|
||||
'config' => $config,
|
||||
'isSetup' => false,
|
||||
];
|
||||
|
||||
$this->render("admin.php", $vars);
|
||||
$data = $this->getAdminData(false);
|
||||
$this->render("admin.php", $data);
|
||||
}
|
||||
|
||||
public function showSetup(){
|
||||
global $config;
|
||||
global $user;
|
||||
|
||||
$vars = [
|
||||
'user' => $user,
|
||||
'config' => $config,
|
||||
'isSetup' => true,
|
||||
$data = $this->getAdminData(true);
|
||||
$this->render("admin.php", $data);
|
||||
}
|
||||
|
||||
public function getAdminData(bool $isSetup): array {
|
||||
Log::debug("Loading admin page" . ($isSetup ? " (setup mode)" : ""));
|
||||
|
||||
return [
|
||||
'user' => $this->user,
|
||||
'config' => $this->config,
|
||||
'isSetup' => $isSetup,
|
||||
];
|
||||
|
||||
$this->render("admin.php", $vars);
|
||||
}
|
||||
|
||||
public function handleSave(){
|
||||
if (!Session::isLoggedIn()){
|
||||
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'login'));
|
||||
header('Location: ' . Util::buildRelativeUrl($this->config->basePath, 'login'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->save();
|
||||
$result = $this->processSettingsSave($_POST, false);
|
||||
header('Location: ' . $_SERVER['PHP_SELF']);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function handleSetup(){
|
||||
// for setup, we don't care if they're logged in
|
||||
// (because they can't be until setup is complete)
|
||||
$this->save();
|
||||
}
|
||||
|
||||
// save updated settings
|
||||
private function save(){
|
||||
global $config;
|
||||
global $user;
|
||||
|
||||
// handle form submission
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$errors = [];
|
||||
|
||||
// User profile
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$displayName = trim($_POST['display_name'] ?? '');
|
||||
$website = trim($_POST['website'] ?? '');
|
||||
|
||||
// Site settings
|
||||
$siteTitle = trim($_POST['site_title']) ?? '';
|
||||
$siteDescription = trim($_POST['site_description']) ?? '';
|
||||
$baseUrl = trim($_POST['base_url'] ?? '');
|
||||
$basePath = trim($_POST['base_path'] ?? '/');
|
||||
$itemsPerPage = (int) ($_POST['items_per_page'] ?? 25);
|
||||
$strictAccessibility = isset($_POST['strict_accessibility']);
|
||||
$logLevel = (int) ($_POST['log_level'] ?? '');
|
||||
|
||||
// Password
|
||||
$password = $_POST['password'] ?? '';
|
||||
$confirmPassword = $_POST['confirm_password'] ?? '';
|
||||
|
||||
// Validate user profile
|
||||
if (!$username) {
|
||||
$errors[] = "Username is required.";
|
||||
}
|
||||
if (!$displayName) {
|
||||
$errors[] = "Display name is required.";
|
||||
}
|
||||
if (!$baseUrl) {
|
||||
$errors[] = "Base URL is required.";
|
||||
}
|
||||
// Make sure the website looks like a URL and starts with a protocol
|
||||
if ($website) {
|
||||
if (!filter_var($website, FILTER_VALIDATE_URL)) {
|
||||
$errors[] = "Please enter a valid URL (including http:// or https://).";
|
||||
} elseif (!preg_match('/^https?:\/\//i', $website)) {
|
||||
$errors[] = "URL must start with http:// or https://.";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate site settings
|
||||
if (!$siteTitle) {
|
||||
$errors[] = "Site title is required.";
|
||||
}
|
||||
if (!preg_match('#^/[^?<>:"|\\*]*$#', $basePath)) {
|
||||
$errors[] = "Base path must look like a valid URL path (e.g. / or /tkr/).";
|
||||
}
|
||||
if ($itemsPerPage < 1 || $itemsPerPage > 50) {
|
||||
$errors[] = "Items per page must be a number between 1 and 50.";
|
||||
}
|
||||
|
||||
// If a password was sent, make sure it matches the confirmation
|
||||
if ($password && !($password === $confirmPassword)){
|
||||
$errors[] = "Passwords do not match";
|
||||
}
|
||||
|
||||
// Validation complete
|
||||
if (empty($errors)) {
|
||||
// Update site settings
|
||||
$config->siteTitle = $siteTitle;
|
||||
$config->siteDescription = $siteDescription;
|
||||
$config->baseUrl = $baseUrl;
|
||||
$config->basePath = $basePath;
|
||||
$config->itemsPerPage = $itemsPerPage;
|
||||
$config->strictAccessibility = $strictAccessibility;
|
||||
$config->logLevel = $logLevel;
|
||||
|
||||
// Save site settings and reload config from database
|
||||
// TODO - raise and handle exception on failure
|
||||
$config = $config->save();
|
||||
|
||||
// Update user profile
|
||||
$user->username = $username;
|
||||
$user->displayName = $displayName;
|
||||
$user->website = $website;
|
||||
|
||||
// Save user profile and reload user from database
|
||||
// TODO - raise and handle exception on failure
|
||||
$user = $user->save();
|
||||
|
||||
// Update the password if one was sent
|
||||
// TODO - raise and handle exception on failure
|
||||
if($password){
|
||||
$user->setPassword($password);
|
||||
}
|
||||
|
||||
Session::setFlashMessage('success', 'Settings updated');
|
||||
} else {
|
||||
foreach($errors as $error){
|
||||
Session::setFlashMessage('error', $error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->processSettingsSave($_POST, true);
|
||||
header('Location: ' . $_SERVER['PHP_SELF']);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function processSettingsSave(array $postData, bool $isSetup): array {
|
||||
$result = ['success' => false, 'errors' => []];
|
||||
|
||||
Log::debug("Processing settings save" . ($isSetup ? " (setup mode)" : ""));
|
||||
|
||||
// handle form submission
|
||||
if (empty($postData)) {
|
||||
Log::warning("Settings save called with no POST data");
|
||||
$result['errors'][] = 'No data provided';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
// User profile
|
||||
$username = trim($postData['username'] ?? '');
|
||||
$displayName = trim($postData['display_name'] ?? '');
|
||||
$website = trim($postData['website'] ?? '');
|
||||
|
||||
// Site settings
|
||||
$siteTitle = trim($postData['site_title'] ?? '');
|
||||
$siteDescription = trim($postData['site_description'] ?? '');
|
||||
$baseUrl = trim($postData['base_url'] ?? '');
|
||||
$basePath = trim($postData['base_path'] ?? '/');
|
||||
$itemsPerPage = (int) ($postData['items_per_page'] ?? 25);
|
||||
$strictAccessibility = isset($postData['strict_accessibility']);
|
||||
$logLevel = (int) ($postData['log_level'] ?? 0);
|
||||
|
||||
// Password
|
||||
$password = $postData['password'] ?? '';
|
||||
$confirmPassword = $postData['confirm_password'] ?? '';
|
||||
|
||||
Log::info("Processing settings for user: $username");
|
||||
|
||||
// Validate user profile
|
||||
if (!$username) {
|
||||
$errors[] = "Username is required.";
|
||||
}
|
||||
if (!$displayName) {
|
||||
$errors[] = "Display name is required.";
|
||||
}
|
||||
if (!$baseUrl) {
|
||||
$errors[] = "Base URL is required.";
|
||||
}
|
||||
// Make sure the website looks like a URL and starts with a protocol
|
||||
if ($website) {
|
||||
if (!filter_var($website, FILTER_VALIDATE_URL)) {
|
||||
$errors[] = "Please enter a valid URL (including http:// or https://).";
|
||||
} elseif (!preg_match('/^https?:\/\//i', $website)) {
|
||||
$errors[] = "URL must start with http:// or https://.";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate site settings
|
||||
if (!$siteTitle) {
|
||||
$errors[] = "Site title is required.";
|
||||
}
|
||||
if (!preg_match('#^/[^?<>:"|\\*]*$#', $basePath)) {
|
||||
$errors[] = "Base path must look like a valid URL path (e.g. / or /tkr/).";
|
||||
}
|
||||
if ($itemsPerPage < 1 || $itemsPerPage > 50) {
|
||||
$errors[] = "Items per page must be a number between 1 and 50.";
|
||||
}
|
||||
|
||||
// If a password was sent, make sure it matches the confirmation
|
||||
if ($password && !($password === $confirmPassword)){
|
||||
$errors[] = "Passwords do not match";
|
||||
}
|
||||
|
||||
// Log validation results
|
||||
if (!empty($errors)) {
|
||||
Log::warning("Settings validation failed with " . count($errors) . " errors");
|
||||
foreach ($errors as $error) {
|
||||
Log::debug("Validation error: $error");
|
||||
}
|
||||
}
|
||||
|
||||
// Validation complete
|
||||
if (empty($errors)) {
|
||||
try {
|
||||
// Update site settings
|
||||
$this->config->siteTitle = $siteTitle;
|
||||
$this->config->siteDescription = $siteDescription;
|
||||
$this->config->baseUrl = $baseUrl;
|
||||
$this->config->basePath = $basePath;
|
||||
$this->config->itemsPerPage = $itemsPerPage;
|
||||
$this->config->strictAccessibility = $strictAccessibility;
|
||||
$this->config->logLevel = $logLevel;
|
||||
|
||||
// Save site settings and reload config from database
|
||||
$this->config = $this->config->save();
|
||||
Log::info("Site settings updated");
|
||||
|
||||
// Update user profile
|
||||
$this->user->username = $username;
|
||||
$this->user->displayName = $displayName;
|
||||
$this->user->website = $website;
|
||||
|
||||
// Save user profile and reload user from database
|
||||
$this->user = $this->user->save();
|
||||
Log::info("User profile updated");
|
||||
|
||||
// Update the password if one was sent
|
||||
if($password){
|
||||
$this->user->setPassword($password);
|
||||
Log::info("User password updated");
|
||||
}
|
||||
|
||||
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');
|
||||
$result['errors'][] = 'Failed to save settings';
|
||||
}
|
||||
} else {
|
||||
foreach($errors as $error){
|
||||
Session::setFlashMessage('error', $error);
|
||||
}
|
||||
$result['errors'] = $errors;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
class Controller {
|
||||
public function __construct(protected PDO $db, protected ConfigModel $config, protected UserModel $user) {}
|
||||
|
||||
// Renders the requested template inside templates/main/php
|
||||
protected function render(string $childTemplateFile, array $vars = []) {
|
||||
$templatePath = TEMPLATES_DIR . "/main.php";
|
||||
|
@ -1,12 +1,12 @@
|
||||
<?php
|
||||
class FeedController extends Controller {
|
||||
private $config;
|
||||
private $ticks;
|
||||
|
||||
public function __construct(){
|
||||
$this->config = ConfigModel::load();
|
||||
$tickModel = new TickModel();
|
||||
$this->ticks = $tickModel->getPage($this->config->itemsPerPage);
|
||||
public function __construct(PDO $db, ConfigModel $config, UserModel $user){
|
||||
parent::__construct($db, $config, $user);
|
||||
|
||||
$tickModel = new TickModel($db, $config);
|
||||
$this->ticks = $tickModel->getPage($config->itemsPerPage);
|
||||
|
||||
Log::debug("Loaded " . count($this->ticks) . " ticks for feeds");
|
||||
}
|
||||
|
@ -4,43 +4,68 @@ class HomeController extends Controller {
|
||||
// renders the homepage view.
|
||||
public function index(){
|
||||
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||
global $config;
|
||||
global $user;
|
||||
$data = $this->getHomeData($page);
|
||||
$this->render("home.php", $data);
|
||||
}
|
||||
|
||||
public function getHomeData(int $page): array {
|
||||
Log::debug("Loading home page $page");
|
||||
|
||||
$tickModel = new TickModel();
|
||||
$limit = $config->itemsPerPage;
|
||||
$tickModel = new TickModel($this->db, $this->config);
|
||||
$limit = $this->config->itemsPerPage;
|
||||
$offset = ($page - 1) * $limit;
|
||||
$ticks = $tickModel->getPage($limit, $offset);
|
||||
|
||||
$view = new TicksView($config, $ticks, $page);
|
||||
$view = new TicksView($this->config, $ticks, $page);
|
||||
$tickList = $view->getHtml();
|
||||
|
||||
$vars = [
|
||||
'config' => $config,
|
||||
'user' => $user,
|
||||
Log::info("Home page loaded with " . count($ticks) . " ticks");
|
||||
|
||||
return [
|
||||
'config' => $this->config,
|
||||
'user' => $this->user,
|
||||
'tickList' => $tickList,
|
||||
];
|
||||
|
||||
$this->render("home.php", $vars);
|
||||
}
|
||||
|
||||
// POST handler
|
||||
// Saves the tick and reloads the homepage
|
||||
public function handleTick(){
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' and isset($_POST['new_tick'])) {
|
||||
// save the tick
|
||||
if (trim($_POST['new_tick'])){
|
||||
$tickModel = new TickModel();
|
||||
$tickModel->insert($_POST['new_tick']);
|
||||
}
|
||||
}
|
||||
|
||||
// get the config
|
||||
global $config;
|
||||
|
||||
$result = $this->processTick($_POST);
|
||||
|
||||
// redirect to the index (will show the latest tick if one was sent)
|
||||
header('Location: ' . Util::buildRelativeUrl($config->basePath));
|
||||
header('Location: ' . Util::buildRelativeUrl($this->config->basePath));
|
||||
exit;
|
||||
}
|
||||
|
||||
public function processTick(array $postData): array {
|
||||
$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($this->db, $this->config);
|
||||
$tickModel->insert($tickContent);
|
||||
Log::info("New tick created: " . substr($tickContent, 0, 50) . (strlen($tickContent) > 50 ? '...' : ''));
|
||||
$result['success'] = true;
|
||||
$result['message'] = 'Tick saved successfully';
|
||||
} catch (Exception $e) {
|
||||
Log::error("Failed to save tick: " . $e->getMessage());
|
||||
$result['message'] = 'Failed to save tick';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
@ -2,7 +2,8 @@
|
||||
class LogController extends Controller {
|
||||
private string $storageDir;
|
||||
|
||||
public function __construct(?string $storageDir = null) {
|
||||
public function __construct(PDO $db, ConfigModel $config, UserModel $user, ?string $storageDir = null) {
|
||||
parent::__construct($db, $config, $user);
|
||||
$this->storageDir = $storageDir ?? STORAGE_DIR;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
// Very simple router class
|
||||
class Router {
|
||||
public function __construct(private PDO $db, private ConfigModel $config, private UserModel $user) {}
|
||||
|
||||
// Define the recognized routes.
|
||||
// Anything else will 404.
|
||||
private static $routeHandlers = [
|
||||
@ -28,7 +30,7 @@ class Router {
|
||||
|
||||
|
||||
// Main router function
|
||||
public static function route(string $requestPath, string $requestMethod): bool {
|
||||
public function route(string $requestPath, string $requestMethod): bool {
|
||||
foreach (self::$routeHandlers as $routeHandler) {
|
||||
$routePattern = $routeHandler[0];
|
||||
$controller = $routeHandler[1];
|
||||
@ -59,7 +61,7 @@ class Router {
|
||||
|
||||
Log::debug("Handling request with Controller {$controllerName} and function {$functionName}");
|
||||
|
||||
$instance = new $controllerName();
|
||||
$instance = new $controllerName($this->db, $this->config, $this->user);
|
||||
call_user_func_array([$instance, $functionName], $matches);
|
||||
return true;
|
||||
}
|
||||
|
@ -11,15 +11,23 @@ class ConfigModel {
|
||||
public bool $strictAccessibility = true;
|
||||
public ?int $logLevel = null;
|
||||
|
||||
// load config from sqlite database
|
||||
public function __construct(private PDO $db) {}
|
||||
|
||||
// load config from sqlite database (backward compatibility)
|
||||
public static function load(): self {
|
||||
global $db;
|
||||
$instance = new self($db);
|
||||
return $instance->loadFromDatabase();
|
||||
}
|
||||
|
||||
// Instance method that uses injected database
|
||||
public function loadFromDatabase(): self {
|
||||
$init = require APP_ROOT . '/config/init.php';
|
||||
$c = new self();
|
||||
$c = new self($this->db);
|
||||
$c->baseUrl = ($c->baseUrl === '') ? $init['base_url'] : $c->baseUrl;
|
||||
$c->basePath = ($c->basePath === '') ? $init['base_path'] : $c->basePath;
|
||||
|
||||
global $db;
|
||||
$stmt = $db->query("SELECT site_title,
|
||||
$stmt = $this->db->query("SELECT site_title,
|
||||
site_description,
|
||||
base_url,
|
||||
base_path,
|
||||
@ -58,11 +66,10 @@ class ConfigModel {
|
||||
}
|
||||
|
||||
public function save(): self {
|
||||
global $db;
|
||||
$settingsCount = (int) $db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
|
||||
$settingsCount = (int) $this->db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
|
||||
|
||||
if ($settingsCount === 0){
|
||||
$stmt = $db->prepare("INSERT INTO settings (
|
||||
$stmt = $this->db->prepare("INSERT INTO settings (
|
||||
id,
|
||||
site_title,
|
||||
site_description,
|
||||
@ -75,7 +82,7 @@ class ConfigModel {
|
||||
)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE settings SET
|
||||
$stmt = $this->db->prepare("UPDATE settings SET
|
||||
site_title=?,
|
||||
site_description=?,
|
||||
base_url=?,
|
||||
@ -97,6 +104,6 @@ class ConfigModel {
|
||||
$this->logLevel
|
||||
]);
|
||||
|
||||
return self::load();
|
||||
return $this->loadFromDatabase();
|
||||
}
|
||||
}
|
||||
|
@ -1,27 +1,24 @@
|
||||
<?php
|
||||
class TickModel {
|
||||
public function __construct(private PDO $db, private ConfigModel $config) {}
|
||||
|
||||
public function getPage(int $limit, int $offset = 0): array {
|
||||
global $db;
|
||||
|
||||
$stmt = $db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?");
|
||||
$stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?");
|
||||
$stmt->execute([$limit, $offset]);
|
||||
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function insert(string $tick, ?DateTimeImmutable $datetime = null): void {
|
||||
global $db;
|
||||
$datetime ??= new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
$timestamp = $datetime->format('Y-m-d H:i:s');
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO tick(timestamp, tick) values (?, ?)");
|
||||
$stmt = $this->db->prepare("INSERT INTO tick(timestamp, tick) values (?, ?)");
|
||||
$stmt->execute([$timestamp, $tick]);
|
||||
}
|
||||
|
||||
public function get(int $id): array {
|
||||
global $db;
|
||||
|
||||
$stmt = $db->prepare("SELECT timestamp, tick FROM tick WHERE id=?");
|
||||
$stmt = $this->db->prepare("SELECT timestamp, tick FROM tick WHERE id=?");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@ -29,7 +26,7 @@ class TickModel {
|
||||
return [
|
||||
'tickTime' => $row['timestamp'],
|
||||
'tick' => $row['tick'],
|
||||
'config' => ConfigModel::load(),
|
||||
'config' => $this->config,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -6,14 +6,21 @@ class UserModel {
|
||||
public string $website = '';
|
||||
public string $mood = '';
|
||||
|
||||
// load user settings from sqlite database
|
||||
public function __construct(private PDO $db) {}
|
||||
|
||||
// load user settings from sqlite database (backward compatibility)
|
||||
public static function load(): self {
|
||||
global $db;
|
||||
|
||||
$instance = new self($db);
|
||||
return $instance->loadFromDatabase();
|
||||
}
|
||||
|
||||
// Instance method that uses injected database
|
||||
public function loadFromDatabase(): self {
|
||||
// There's only ever one user. I'm just leaning into that.
|
||||
$stmt = $db->query("SELECT username, display_name, website, mood FROM user WHERE id=1");
|
||||
$stmt = $this->db->query("SELECT username, display_name, website, mood FROM user WHERE id=1");
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$u = new self();
|
||||
$u = new self($this->db);
|
||||
|
||||
if ($row) {
|
||||
$u->username = $row['username'];
|
||||
@ -26,33 +33,29 @@ class UserModel {
|
||||
}
|
||||
|
||||
public function save(): self {
|
||||
global $db;
|
||||
$userCount = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn();
|
||||
$userCount = (int) $this->db->query("SELECT COUNT(*) FROM user")->fetchColumn();
|
||||
|
||||
if ($userCount === 0){
|
||||
$stmt = $db->prepare("INSERT INTO user (id, username, display_name, website, mood) VALUES (1, ?, ?, ?, ?)");
|
||||
$stmt = $this->db->prepare("INSERT INTO user (id, username, display_name, website, mood) VALUES (1, ?, ?, ?, ?)");
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE user SET username=?, display_name=?, website=?, mood=? WHERE id=1");
|
||||
$stmt = $this->db->prepare("UPDATE user SET username=?, display_name=?, website=?, mood=? WHERE id=1");
|
||||
}
|
||||
|
||||
$stmt->execute([$this->username, $this->displayName, $this->website, $this->mood]);
|
||||
|
||||
return self::load();
|
||||
return $this->loadFromDatabase();
|
||||
}
|
||||
|
||||
// Making this a separate function to avoid
|
||||
// loading the password into memory
|
||||
public function setPassword(string $password): void {
|
||||
global $db;
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$stmt = $db->prepare("UPDATE user SET password_hash=? WHERE id=1");
|
||||
$stmt = $this->db->prepare("UPDATE user SET password_hash=? WHERE id=1");
|
||||
$stmt->execute([$hash]);
|
||||
}
|
||||
|
||||
public function getByUsername($username){
|
||||
global $db;
|
||||
$stmt = $db->prepare("SELECT id, username, password_hash FROM user WHERE username = ?");
|
||||
$stmt = $this->db->prepare("SELECT id, username, password_hash FROM user WHERE username = ?");
|
||||
$stmt->execute([$username]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
|
367
tests/Controller/AdminController/AdminControllerTest.php
Normal file
367
tests/Controller/AdminController/AdminControllerTest.php
Normal file
@ -0,0 +1,367 @@
|
||||
<?php
|
||||
require_once dirname(dirname(dirname(__DIR__))) . "/config/bootstrap.php";
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AdminControllerTest extends TestCase
|
||||
{
|
||||
private PDO $mockPdo;
|
||||
private ConfigModel $config;
|
||||
private UserModel $user;
|
||||
private string $tempLogDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Set up temporary logging
|
||||
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
|
||||
mkdir($this->tempLogDir . '/logs', 0777, true);
|
||||
Log::init($this->tempLogDir . '/logs/tkr.log');
|
||||
|
||||
// Set up global config for logging level (DEBUG = 1)
|
||||
global $config;
|
||||
$config = new stdClass();
|
||||
$config->logLevel = 1; // Allow DEBUG level logs
|
||||
|
||||
// Create mock PDO (needed for base constructor)
|
||||
$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->user = new UserModel($this->mockPdo);
|
||||
$this->user->username = 'testuser';
|
||||
$this->user->displayName = 'Test User';
|
||||
$this->user->website = 'https://example.com';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up temp directory
|
||||
if (is_dir($this->tempLogDir)) {
|
||||
$this->deleteDirectory($this->tempLogDir);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) return;
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
public function testGetAdminDataRegularMode(): void
|
||||
{
|
||||
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
|
||||
$data = $controller->getAdminData(false);
|
||||
|
||||
// Should return proper structure
|
||||
$this->assertArrayHasKey('config', $data);
|
||||
$this->assertArrayHasKey('user', $data);
|
||||
$this->assertArrayHasKey('isSetup', $data);
|
||||
|
||||
// Should be the injected instances
|
||||
$this->assertSame($this->config, $data['config']);
|
||||
$this->assertSame($this->user, $data['user']);
|
||||
$this->assertFalse($data['isSetup']);
|
||||
}
|
||||
|
||||
public function testGetAdminDataSetupMode(): void
|
||||
{
|
||||
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
|
||||
$data = $controller->getAdminData(true);
|
||||
|
||||
// Should return proper structure
|
||||
$this->assertArrayHasKey('config', $data);
|
||||
$this->assertArrayHasKey('user', $data);
|
||||
$this->assertArrayHasKey('isSetup', $data);
|
||||
|
||||
// Should be the injected instances
|
||||
$this->assertSame($this->config, $data['config']);
|
||||
$this->assertSame($this->user, $data['user']);
|
||||
$this->assertTrue($data['isSetup']);
|
||||
}
|
||||
|
||||
public function testProcessSettingsSaveWithEmptyData(): void
|
||||
{
|
||||
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
|
||||
$result = $controller->processSettingsSave([], false);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertContains('No data provided', $result['errors']);
|
||||
}
|
||||
|
||||
public function testProcessSettingsSaveValidationErrors(): void
|
||||
{
|
||||
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
|
||||
|
||||
// Test data with multiple validation errors
|
||||
$postData = [
|
||||
'username' => '', // Missing username
|
||||
'display_name' => '', // Missing display name
|
||||
'website' => 'invalid-url', // Invalid URL
|
||||
'site_title' => '', // Missing site title
|
||||
'base_url' => '', // Missing base URL
|
||||
'base_path' => 'invalid', // Invalid base path
|
||||
'items_per_page' => 100, // Too high
|
||||
'password' => 'test123',
|
||||
'confirm_password' => 'different' // Passwords don't match
|
||||
];
|
||||
|
||||
$result = $controller->processSettingsSave($postData, false);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertNotEmpty($result['errors']);
|
||||
|
||||
// Should have multiple validation errors
|
||||
$this->assertGreaterThan(5, count($result['errors']));
|
||||
}
|
||||
|
||||
public function testProcessSettingsSaveValidData(): void
|
||||
{
|
||||
// Mock PDO to simulate successful database operations
|
||||
$mockStatement = $this->createMock(PDOStatement::class);
|
||||
$mockStatement->method('execute')->willReturn(true);
|
||||
$mockStatement->method('fetchColumn')->willReturn(1); // Existing record count
|
||||
$mockStatement->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
[
|
||||
'site_title' => 'Updated Site',
|
||||
'site_description' => 'Updated Description',
|
||||
'base_url' => 'https://updated.com',
|
||||
'base_path' => '/updated',
|
||||
'items_per_page' => 15,
|
||||
'css_id' => null,
|
||||
'strict_accessibility' => true,
|
||||
'log_level' => 2
|
||||
],
|
||||
[
|
||||
'username' => 'newuser',
|
||||
'display_name' => 'New User',
|
||||
'website' => 'https://example.com',
|
||||
'mood' => ''
|
||||
]
|
||||
);
|
||||
|
||||
$this->mockPdo->method('prepare')->willReturn($mockStatement);
|
||||
$this->mockPdo->method('query')->willReturn($mockStatement);
|
||||
|
||||
// Create models with mocked PDO
|
||||
$config = new ConfigModel($this->mockPdo);
|
||||
$user = new UserModel($this->mockPdo);
|
||||
|
||||
$controller = new AdminController($this->mockPdo, $config, $user);
|
||||
|
||||
$postData = [
|
||||
'username' => 'newuser',
|
||||
'display_name' => 'New User',
|
||||
'website' => 'https://example.com',
|
||||
'site_title' => 'Updated Site',
|
||||
'site_description' => 'Updated Description',
|
||||
'base_url' => 'https://updated.com',
|
||||
'base_path' => '/updated',
|
||||
'items_per_page' => 15,
|
||||
'strict_accessibility' => 'on',
|
||||
'log_level' => 2
|
||||
];
|
||||
|
||||
$result = $controller->processSettingsSave($postData, false);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEmpty($result['errors']);
|
||||
}
|
||||
|
||||
public function testProcessSettingsSaveWithPassword(): void
|
||||
{
|
||||
// Mock PDO for successful save operations
|
||||
$mockStatement = $this->createMock(PDOStatement::class);
|
||||
$mockStatement->method('execute')->willReturn(true);
|
||||
$mockStatement->method('fetchColumn')->willReturn(1);
|
||||
$mockStatement->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
[
|
||||
'site_title' => 'Test Site',
|
||||
'site_description' => 'Test Description',
|
||||
'base_url' => 'https://example.com',
|
||||
'base_path' => '/tkr',
|
||||
'items_per_page' => 10,
|
||||
'css_id' => null,
|
||||
'strict_accessibility' => true,
|
||||
'log_level' => 2
|
||||
],
|
||||
[
|
||||
'username' => 'testuser',
|
||||
'display_name' => 'Test User',
|
||||
'website' => '',
|
||||
'mood' => ''
|
||||
]
|
||||
);
|
||||
|
||||
// Verify password hash is called
|
||||
$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);
|
||||
$user = new UserModel($this->mockPdo);
|
||||
|
||||
$controller = new AdminController($this->mockPdo, $config, $user);
|
||||
|
||||
$postData = [
|
||||
'username' => 'testuser',
|
||||
'display_name' => 'Test User',
|
||||
'site_title' => 'Test Site',
|
||||
'site_description' => 'Test Description',
|
||||
'base_url' => 'https://example.com',
|
||||
'base_path' => '/tkr',
|
||||
'items_per_page' => 10,
|
||||
'password' => 'newpassword',
|
||||
'confirm_password' => 'newpassword'
|
||||
];
|
||||
|
||||
$result = $controller->processSettingsSave($postData, false);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
}
|
||||
|
||||
public function testProcessSettingsSaveDatabaseError(): void
|
||||
{
|
||||
// Mock PDO to throw exception on save
|
||||
$this->mockPdo->method('query')
|
||||
->willThrowException(new PDOException("Database error"));
|
||||
|
||||
$config = new ConfigModel($this->mockPdo);
|
||||
$user = new UserModel($this->mockPdo);
|
||||
|
||||
$controller = new AdminController($this->mockPdo, $config, $user);
|
||||
|
||||
$postData = [
|
||||
'username' => 'testuser',
|
||||
'display_name' => 'Test User',
|
||||
'site_title' => 'Test Site',
|
||||
'site_description' => 'Test Description',
|
||||
'base_url' => 'https://example.com',
|
||||
'base_path' => '/tkr',
|
||||
'items_per_page' => 10
|
||||
];
|
||||
|
||||
$result = $controller->processSettingsSave($postData, false);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertContains('Failed to save settings', $result['errors']);
|
||||
}
|
||||
|
||||
public function testLoggingOnAdminPageLoad(): void
|
||||
{
|
||||
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
|
||||
$controller->getAdminData(false);
|
||||
|
||||
// Check that logs were written
|
||||
$logFile = $this->tempLogDir . '/logs/tkr.log';
|
||||
$this->assertFileExists($logFile);
|
||||
|
||||
$logContent = file_get_contents($logFile);
|
||||
$this->assertStringContainsString('Loading admin page', $logContent);
|
||||
}
|
||||
|
||||
public function testLoggingOnSetupPageLoad(): void
|
||||
{
|
||||
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
|
||||
$controller->getAdminData(true);
|
||||
|
||||
// Check that logs were written
|
||||
$logFile = $this->tempLogDir . '/logs/tkr.log';
|
||||
$this->assertFileExists($logFile);
|
||||
|
||||
$logContent = file_get_contents($logFile);
|
||||
$this->assertStringContainsString('Loading admin page (setup mode)', $logContent);
|
||||
}
|
||||
|
||||
public function testLoggingOnValidationErrors(): void
|
||||
{
|
||||
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
|
||||
|
||||
$postData = [
|
||||
'username' => '', // Will cause validation error
|
||||
'display_name' => 'Test User',
|
||||
'site_title' => 'Test Site',
|
||||
'base_url' => 'https://example.com',
|
||||
'base_path' => '/tkr',
|
||||
'items_per_page' => 10
|
||||
];
|
||||
|
||||
$controller->processSettingsSave($postData, false);
|
||||
|
||||
// Check that logs were written
|
||||
$logFile = $this->tempLogDir . '/logs/tkr.log';
|
||||
$this->assertFileExists($logFile);
|
||||
|
||||
$logContent = file_get_contents($logFile);
|
||||
$this->assertStringContainsString('Settings validation failed', $logContent);
|
||||
$this->assertStringContainsString('Validation error: Username is required', $logContent);
|
||||
}
|
||||
|
||||
public function testLoggingOnSuccessfulSave(): void
|
||||
{
|
||||
// Mock successful database operations
|
||||
$mockStatement = $this->createMock(PDOStatement::class);
|
||||
$mockStatement->method('execute')->willReturn(true);
|
||||
$mockStatement->method('fetchColumn')->willReturn(1);
|
||||
$mockStatement->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
[
|
||||
'site_title' => 'Test Site',
|
||||
'site_description' => 'Test Description',
|
||||
'base_url' => 'https://example.com',
|
||||
'base_path' => '/tkr',
|
||||
'items_per_page' => 10,
|
||||
'css_id' => null,
|
||||
'strict_accessibility' => true,
|
||||
'log_level' => 2
|
||||
],
|
||||
[
|
||||
'username' => 'testuser',
|
||||
'display_name' => 'Test User',
|
||||
'website' => '',
|
||||
'mood' => ''
|
||||
]
|
||||
);
|
||||
|
||||
$this->mockPdo->method('prepare')->willReturn($mockStatement);
|
||||
$this->mockPdo->method('query')->willReturn($mockStatement);
|
||||
|
||||
$config = new ConfigModel($this->mockPdo);
|
||||
$user = new UserModel($this->mockPdo);
|
||||
|
||||
$controller = new AdminController($this->mockPdo, $config, $user);
|
||||
|
||||
$postData = [
|
||||
'username' => 'testuser',
|
||||
'display_name' => 'Test User',
|
||||
'site_title' => 'Test Site',
|
||||
'site_description' => 'Test Description',
|
||||
'base_url' => 'https://example.com',
|
||||
'base_path' => '/tkr',
|
||||
'items_per_page' => 10
|
||||
];
|
||||
|
||||
$controller->processSettingsSave($postData, false);
|
||||
|
||||
// Check that logs were written
|
||||
$logFile = $this->tempLogDir . '/logs/tkr.log';
|
||||
$this->assertFileExists($logFile);
|
||||
|
||||
$logContent = file_get_contents($logFile);
|
||||
$this->assertStringContainsString('Processing settings for user: testuser', $logContent);
|
||||
$this->assertStringContainsString('Site settings updated', $logContent);
|
||||
$this->assertStringContainsString('User profile updated', $logContent);
|
||||
}
|
||||
}
|
175
tests/Controller/FeedController/FeedControllerTest.php
Normal file
175
tests/Controller/FeedController/FeedControllerTest.php
Normal file
@ -0,0 +1,175 @@
|
||||
<?php
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FeedControllerTest extends TestCase
|
||||
{
|
||||
private PDO $mockPdo;
|
||||
private PDOStatement $mockStatement;
|
||||
private ConfigModel $mockConfig;
|
||||
private UserModel $mockUser;
|
||||
private string $tempLogDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Set up temporary logging
|
||||
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
|
||||
mkdir($this->tempLogDir . '/logs', 0777, true);
|
||||
Log::init($this->tempLogDir . '/logs/tkr.log');
|
||||
|
||||
// Set up global config for logging level (DEBUG = 1)
|
||||
global $config;
|
||||
$config = new stdClass();
|
||||
$config->logLevel = 1; // Allow DEBUG level logs
|
||||
|
||||
// Create mock PDO 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->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';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up temp directory
|
||||
if (is_dir($this->tempLogDir)) {
|
||||
$this->deleteDirectory($this->tempLogDir);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) return;
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
private function setupMockDatabase(array $tickData): void
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
public function testControllerInstantiationWithNoTicks(): void
|
||||
{
|
||||
$this->setupMockDatabase([]);
|
||||
|
||||
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
public function testControllerInstantiationWithTicks(): void
|
||||
{
|
||||
$testTicks = [
|
||||
['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($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
}
|
||||
|
||||
public function testRssMethodLogsCorrectly(): void
|
||||
{
|
||||
$testTicks = [
|
||||
['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'Test tick']
|
||||
];
|
||||
|
||||
$this->setupMockDatabase($testTicks);
|
||||
|
||||
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
|
||||
// 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);
|
||||
$this->assertStringContainsString('Generating RSS feed with 1 ticks', $logContent);
|
||||
}
|
||||
|
||||
public function testAtomMethodLogsCorrectly(): void
|
||||
{
|
||||
$testTicks = [
|
||||
['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($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
|
||||
// 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);
|
||||
$this->assertStringContainsString('Generating Atom feed with 2 ticks', $logContent);
|
||||
}
|
||||
}
|
312
tests/Controller/HomeController/HomeControllerTest.php
Normal file
312
tests/Controller/HomeController/HomeControllerTest.php
Normal file
@ -0,0 +1,312 @@
|
||||
<?php
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HomeControllerTest extends TestCase
|
||||
{
|
||||
private PDO $mockPdo;
|
||||
private PDOStatement $mockStatement;
|
||||
private ConfigModel $mockConfig;
|
||||
private UserModel $mockUser;
|
||||
private string $tempLogDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Set up temporary logging
|
||||
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
|
||||
mkdir($this->tempLogDir . '/logs', 0777, true);
|
||||
Log::init($this->tempLogDir . '/logs/tkr.log');
|
||||
|
||||
// Set up global config for logging level (DEBUG = 1)
|
||||
global $config;
|
||||
$config = new stdClass();
|
||||
$config->logLevel = 1; // Allow DEBUG level logs
|
||||
|
||||
// Create mock PDO and PDOStatement
|
||||
$this->mockStatement = $this->createMock(PDOStatement::class);
|
||||
$this->mockPdo = $this->createMock(PDO::class);
|
||||
|
||||
// Mock config
|
||||
$this->mockConfig = new ConfigModel($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 = '😊';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up temp directory
|
||||
if (is_dir($this->tempLogDir)) {
|
||||
$this->deleteDirectory($this->tempLogDir);
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) return;
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
private function setupMockDatabase(array $tickData): void
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
private function setupMockDatabaseForInsert(bool $shouldSucceed = true): void
|
||||
{
|
||||
if ($shouldSucceed) {
|
||||
// Mock successful insert
|
||||
$this->mockPdo->method('prepare')
|
||||
->willReturn($this->mockStatement);
|
||||
|
||||
$this->mockStatement->method('execute')
|
||||
->willReturn(true);
|
||||
} else {
|
||||
// Mock database error
|
||||
$this->mockPdo->method('prepare')
|
||||
->willThrowException(new PDOException("Database error"));
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetHomeDataWithNoTicks(): void
|
||||
{
|
||||
$this->setupMockDatabase([]); // Empty array = no ticks
|
||||
|
||||
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$data = $controller->getHomeData(1);
|
||||
|
||||
// Should return proper structure
|
||||
$this->assertArrayHasKey('config', $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->mockUser, $data['user']);
|
||||
|
||||
// Should have tick list HTML (even if empty)
|
||||
$this->assertIsString($data['tickList']);
|
||||
}
|
||||
|
||||
public function testGetHomeDataWithTicks(): void
|
||||
{
|
||||
// Set up test tick data that the database would return
|
||||
$testTicks = [
|
||||
['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'First tick'],
|
||||
['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($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$data = $controller->getHomeData(1);
|
||||
|
||||
// Should return proper structure
|
||||
$this->assertArrayHasKey('config', $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']);
|
||||
$this->assertStringContainsString('Third tick', $data['tickList']);
|
||||
}
|
||||
|
||||
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($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$controller->getHomeData(2); // Page 2
|
||||
}
|
||||
|
||||
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])
|
||||
&& $params[1] === 'This is a test tick';
|
||||
}));
|
||||
|
||||
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$postData = ['new_tick' => 'This is a test tick'];
|
||||
|
||||
$result = $controller->processTick($postData);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('Tick saved successfully', $result['message']);
|
||||
}
|
||||
|
||||
public function testProcessTickEmptyContent(): void
|
||||
{
|
||||
// PDO shouldn't be called at all for empty content
|
||||
$this->mockPdo->expects($this->never())->method('prepare');
|
||||
|
||||
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$postData = ['new_tick' => ' ']; // Just whitespace
|
||||
|
||||
$result = $controller->processTick($postData);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('Empty tick ignored', $result['message']);
|
||||
}
|
||||
|
||||
public function testProcessTickMissingField(): void
|
||||
{
|
||||
// PDO shouldn't be called at all for missing field
|
||||
$this->mockPdo->expects($this->never())->method('prepare');
|
||||
|
||||
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$postData = []; // No new_tick field
|
||||
|
||||
$result = $controller->processTick($postData);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('No tick content provided', $result['message']);
|
||||
}
|
||||
|
||||
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($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$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($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$postData = ['new_tick' => 'This will fail'];
|
||||
|
||||
$result = $controller->processTick($postData);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals('Failed to save tick', $result['message']);
|
||||
}
|
||||
|
||||
public function testLoggingOnHomePageLoad(): void
|
||||
{
|
||||
$testTicks = [
|
||||
['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'Test tick']
|
||||
];
|
||||
$this->setupMockDatabase($testTicks);
|
||||
|
||||
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$controller->getHomeData(1);
|
||||
|
||||
// Check that logs were written
|
||||
$logFile = $this->tempLogDir . '/logs/tkr.log';
|
||||
$this->assertFileExists($logFile);
|
||||
|
||||
$logContent = file_get_contents($logFile);
|
||||
$this->assertStringContainsString('Loading home page 1', $logContent);
|
||||
$this->assertStringContainsString('Home page loaded with 1 ticks', $logContent);
|
||||
}
|
||||
|
||||
public function testLoggingOnTickCreation(): void
|
||||
{
|
||||
$this->setupMockDatabaseForInsert(true);
|
||||
|
||||
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$postData = ['new_tick' => 'Test tick for logging'];
|
||||
|
||||
$controller->processTick($postData);
|
||||
|
||||
// Check that logs were written
|
||||
$logFile = $this->tempLogDir . '/logs/tkr.log';
|
||||
$this->assertFileExists($logFile);
|
||||
|
||||
$logContent = file_get_contents($logFile);
|
||||
$this->assertStringContainsString('New tick created: Test tick for logging', $logContent);
|
||||
}
|
||||
|
||||
public function testLoggingOnEmptyTick(): void
|
||||
{
|
||||
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$postData = ['new_tick' => ''];
|
||||
|
||||
$controller->processTick($postData);
|
||||
|
||||
// Check that logs were written
|
||||
$logFile = $this->tempLogDir . '/logs/tkr.log';
|
||||
|
||||
// The log file should exist (Log::init creates it) and contain the debug message
|
||||
$this->assertFileExists($logFile);
|
||||
|
||||
$logContent = file_get_contents($logFile);
|
||||
$this->assertStringContainsString('Empty tick submission ignored', $logContent);
|
||||
}
|
||||
|
||||
public function testLoggingOnDatabaseError(): void
|
||||
{
|
||||
$this->setupMockDatabaseForInsert(false);
|
||||
|
||||
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
|
||||
$postData = ['new_tick' => 'This will fail'];
|
||||
|
||||
$controller->processTick($postData);
|
||||
|
||||
// Check that logs were written
|
||||
$logFile = $this->tempLogDir . '/logs/tkr.log';
|
||||
$this->assertFileExists($logFile);
|
||||
|
||||
$logContent = file_get_contents($logFile);
|
||||
$this->assertStringContainsString('Failed to save tick: Database error', $logContent);
|
||||
}
|
||||
}
|
@ -21,7 +21,8 @@ class LogControllerTest extends TestCase
|
||||
|
||||
// Mock global config
|
||||
global $config;
|
||||
$config = new ConfigModel();
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$config = new ConfigModel($mockPdo);
|
||||
$config->baseUrl = 'https://example.com';
|
||||
$config->basePath = '/tkr/';
|
||||
}
|
||||
@ -50,7 +51,10 @@ class LogControllerTest extends TestCase
|
||||
|
||||
public function testGetLogDataWithNoLogFiles(): void
|
||||
{
|
||||
$controller = new LogController($this->tempLogDir);
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$mockConfig = new ConfigModel($mockPdo);
|
||||
$mockUser = new UserModel($mockPdo);
|
||||
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
|
||||
$data = $controller->getLogData();
|
||||
|
||||
// Should return empty log entries but valid structure
|
||||
@ -81,7 +85,10 @@ class LogControllerTest extends TestCase
|
||||
|
||||
file_put_contents($this->testLogFile, $logContent);
|
||||
|
||||
$controller = new LogController($this->tempLogDir);
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$mockConfig = new ConfigModel($mockPdo);
|
||||
$mockUser = new UserModel($mockPdo);
|
||||
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
|
||||
$data = $controller->getLogData();
|
||||
|
||||
// Should parse all valid entries and ignore invalid ones
|
||||
@ -122,7 +129,10 @@ class LogControllerTest extends TestCase
|
||||
|
||||
file_put_contents($this->testLogFile, $logContent);
|
||||
|
||||
$controller = new LogController($this->tempLogDir);
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$mockConfig = new ConfigModel($mockPdo);
|
||||
$mockUser = new UserModel($mockPdo);
|
||||
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
|
||||
$data = $controller->getLogData('ERROR');
|
||||
|
||||
// Should only include ERROR entries
|
||||
@ -142,7 +152,10 @@ class LogControllerTest extends TestCase
|
||||
|
||||
file_put_contents($this->testLogFile, $logContent);
|
||||
|
||||
$controller = new LogController($this->tempLogDir);
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$mockConfig = new ConfigModel($mockPdo);
|
||||
$mockUser = new UserModel($mockPdo);
|
||||
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
|
||||
$data = $controller->getLogData('', 'GET /admin');
|
||||
|
||||
// Should only include GET /admin entries
|
||||
@ -162,7 +175,10 @@ class LogControllerTest extends TestCase
|
||||
|
||||
file_put_contents($this->testLogFile, $logContent);
|
||||
|
||||
$controller = new LogController($this->tempLogDir);
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$mockConfig = new ConfigModel($mockPdo);
|
||||
$mockUser = new UserModel($mockPdo);
|
||||
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
|
||||
$data = $controller->getLogData('ERROR', 'GET /admin');
|
||||
|
||||
// Should only include entries matching both filters
|
||||
@ -185,7 +201,10 @@ class LogControllerTest extends TestCase
|
||||
$rotatedLog2 = '[2025-01-31 12:00:00] WARNING: 127.0.0.1 - Rotated log entry 2';
|
||||
file_put_contents($this->testLogFile . '.2', $rotatedLog2);
|
||||
|
||||
$controller = new LogController($this->tempLogDir);
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$mockConfig = new ConfigModel($mockPdo);
|
||||
$mockUser = new UserModel($mockPdo);
|
||||
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
|
||||
$data = $controller->getLogData();
|
||||
|
||||
// Should read from all log files, newest first
|
||||
@ -207,7 +226,10 @@ class LogControllerTest extends TestCase
|
||||
|
||||
file_put_contents($this->testLogFile, $logContent);
|
||||
|
||||
$controller = new LogController($this->tempLogDir);
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$mockConfig = new ConfigModel($mockPdo);
|
||||
$mockUser = new UserModel($mockPdo);
|
||||
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
|
||||
$data = $controller->getLogData();
|
||||
|
||||
// Should extract unique routes, sorted
|
||||
@ -226,7 +248,10 @@ class LogControllerTest extends TestCase
|
||||
|
||||
file_put_contents($this->testLogFile, $logContent);
|
||||
|
||||
$controller = new LogController($this->tempLogDir);
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$mockConfig = new ConfigModel($mockPdo);
|
||||
$mockUser = new UserModel($mockPdo);
|
||||
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
|
||||
$data = $controller->getLogData();
|
||||
|
||||
// Should only include valid entries, ignore invalid ones
|
||||
|
@ -4,7 +4,8 @@ use PHPUnit\Framework\TestCase;
|
||||
class AtomGeneratorTest extends TestCase
|
||||
{
|
||||
private function createMockConfig() {
|
||||
$config = new ConfigModel();
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$config = new ConfigModel($mockPdo);
|
||||
$config->siteTitle = 'Test Site';
|
||||
$config->siteDescription = 'Test Description';
|
||||
$config->baseUrl = 'https://example.com';
|
||||
|
@ -4,7 +4,8 @@ use PHPUnit\Framework\TestCase;
|
||||
class FeedGeneratorTest extends TestCase
|
||||
{
|
||||
private function createMockConfig() {
|
||||
$config = new ConfigModel();
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$config = new ConfigModel($mockPdo);
|
||||
$config->siteTitle = 'Test Site';
|
||||
$config->siteDescription = 'Test Description';
|
||||
$config->baseUrl = 'https://example.com';
|
||||
@ -65,7 +66,8 @@ class FeedGeneratorTest extends TestCase
|
||||
}
|
||||
|
||||
public function testUrlMethodsHandleSubdomainConfiguration() {
|
||||
$config = new ConfigModel();
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$config = new ConfigModel($mockPdo);
|
||||
$config->siteTitle = 'Test Site';
|
||||
$config->baseUrl = 'https://tkr.example.com';
|
||||
$config->basePath = '/';
|
||||
@ -77,7 +79,8 @@ class FeedGeneratorTest extends TestCase
|
||||
}
|
||||
|
||||
public function testUrlMethodsHandleEmptyBasePath() {
|
||||
$config = new ConfigModel();
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$config = new ConfigModel($mockPdo);
|
||||
$config->siteTitle = 'Test Site';
|
||||
$config->baseUrl = 'https://example.com';
|
||||
$config->basePath = '';
|
||||
@ -100,7 +103,8 @@ class FeedGeneratorTest extends TestCase
|
||||
];
|
||||
|
||||
foreach ($testCases as [$basePath, $expectedSiteUrl, $expectedTickUrl]) {
|
||||
$config = new ConfigModel();
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$config = new ConfigModel($mockPdo);
|
||||
$config->siteTitle = 'Test Site';
|
||||
$config->baseUrl = 'https://example.com';
|
||||
$config->basePath = $basePath;
|
||||
|
@ -4,7 +4,8 @@ use PHPUnit\Framework\TestCase;
|
||||
class RssGeneratorTest extends TestCase
|
||||
{
|
||||
private function createMockConfig() {
|
||||
$config = new ConfigModel();
|
||||
$mockPdo = $this->createMock(PDO::class);
|
||||
$config = new ConfigModel($mockPdo);
|
||||
$config->siteTitle = 'Test Site';
|
||||
$config->siteDescription = 'Test Description';
|
||||
$config->baseUrl = 'https://example.com';
|
||||
|
Loading…
x
Reference in New Issue
Block a user