diff --git a/WebFiori/Framework/App.php b/WebFiori/Framework/App.php
index 4053bea1d..0283f4337 100644
--- a/WebFiori/Framework/App.php
+++ b/WebFiori/Framework/App.php
@@ -374,6 +374,7 @@ public static function getRunner() : Runner {
'\\WebFiori\\Framework\\Cli\\Commands\\MigrationsStatusCommand',
'\\WebFiori\\Framework\\Cli\\Commands\\FreshMigrationsCommand',
'\\WebFiori\\Framework\\Cli\\Commands\\SkipMigrationsCommand',
+ '\\WebFiori\\Framework\\Cli\\Commands\\StepMigrationsCommand',
];
foreach ($commands as $c) {
diff --git a/WebFiori/Framework/Cli/Commands/DownCommand.php b/WebFiori/Framework/Cli/Commands/DownCommand.php
index 86c3186c2..4a18c22be 100644
--- a/WebFiori/Framework/Cli/Commands/DownCommand.php
+++ b/WebFiori/Framework/Cli/Commands/DownCommand.php
@@ -13,10 +13,12 @@
use WebFiori\Cli\Command;
use WebFiori\Cli\Argument;
+use WebFiori\Cli\Attributes\Group;
/**
* A command to put the application in maintenance mode.
*/
+#[Group('maintenance')]
class DownCommand extends Command {
public function __construct() {
parent::__construct('down', [
diff --git a/WebFiori/Framework/Cli/Commands/FreshMigrationsCommand.php b/WebFiori/Framework/Cli/Commands/FreshMigrationsCommand.php
index fd3eeb966..3184f0aa2 100644
--- a/WebFiori/Framework/Cli/Commands/FreshMigrationsCommand.php
+++ b/WebFiori/Framework/Cli/Commands/FreshMigrationsCommand.php
@@ -12,6 +12,7 @@
use Throwable;
use WebFiori\Cli\Argument;
+use WebFiori\Cli\Attributes\SingleInstance;
use WebFiori\Cli\Command;
use WebFiori\Database\ConnectionInfo;
use WebFiori\Database\Schema\SchemaRunner;
@@ -23,6 +24,7 @@
*
* @author Ibrahim
*/
+#[SingleInstance]
class FreshMigrationsCommand extends Command {
private ?SchemaRunner $runner = null;
diff --git a/WebFiori/Framework/Cli/Commands/QueueRetryCommand.php b/WebFiori/Framework/Cli/Commands/QueueRetryCommand.php
index d599bc307..69dc360c7 100644
--- a/WebFiori/Framework/Cli/Commands/QueueRetryCommand.php
+++ b/WebFiori/Framework/Cli/Commands/QueueRetryCommand.php
@@ -12,12 +12,14 @@
namespace WebFiori\Framework\Cli\Commands;
use WebFiori\Cli\Argument;
+use WebFiori\Cli\Attributes\SingleInstance;
use WebFiori\Cli\Command;
use WebFiori\Queue\QueueFacade;
/**
* CLI command to retry failed jobs or flush them.
*/
+#[SingleInstance]
class QueueRetryCommand extends Command {
public function __construct() {
parent::__construct('queue:retry', [
diff --git a/WebFiori/Framework/Cli/Commands/QueueWorkCommand.php b/WebFiori/Framework/Cli/Commands/QueueWorkCommand.php
index 395fb25ee..278cb0fcc 100644
--- a/WebFiori/Framework/Cli/Commands/QueueWorkCommand.php
+++ b/WebFiori/Framework/Cli/Commands/QueueWorkCommand.php
@@ -11,12 +11,14 @@
*/
namespace WebFiori\Framework\Cli\Commands;
+use WebFiori\Cli\Attributes\SingleInstance;
use WebFiori\Cli\Command;
use WebFiori\Queue\QueueFacade;
/**
* CLI command to process queue jobs continuously.
*/
+#[SingleInstance]
class QueueWorkCommand extends Command {
public function __construct() {
parent::__construct('queue:work', [], 'Process queue jobs continuously.');
diff --git a/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php b/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php
index c63167b6c..e935eafb8 100644
--- a/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php
+++ b/WebFiori/Framework/Cli/Commands/RunMigrationsCommandNew.php
@@ -13,6 +13,7 @@
use Throwable;
use WebFiori\Cli\Argument;
+use WebFiori\Cli\Attributes\SingleInstance;
use WebFiori\Cli\Command;
use WebFiori\Database\ConnectionInfo;
use WebFiori\Database\Schema\SchemaRunner;
@@ -24,6 +25,7 @@
*
* @author Ibrahim
*/
+#[SingleInstance]
class RunMigrationsCommandNew extends Command {
private ?SchemaRunner $runner = null;
diff --git a/WebFiori/Framework/Cli/Commands/SchedulerCommand.php b/WebFiori/Framework/Cli/Commands/SchedulerCommand.php
index 7bfcca70e..1aab73bd2 100644
--- a/WebFiori/Framework/Cli/Commands/SchedulerCommand.php
+++ b/WebFiori/Framework/Cli/Commands/SchedulerCommand.php
@@ -11,6 +11,7 @@
namespace WebFiori\Framework\Cli\Commands;
use WebFiori\Cli\Argument;
+use WebFiori\Cli\Attributes\SingleInstance;
use WebFiori\Cli\Command;
use WebFiori\Framework\Cli\CLIUtils;
use WebFiori\Framework\Scheduler\AbstractTask;
@@ -22,6 +23,7 @@
* @author Ibrahim
* @version 1.0
*/
+#[SingleInstance]
class SchedulerCommand extends Command {
/**
* Creates new instance of the class.
diff --git a/WebFiori/Framework/Cli/Commands/SchedulerDaemonCommand.php b/WebFiori/Framework/Cli/Commands/SchedulerDaemonCommand.php
index 0f3c10369..8191234e3 100644
--- a/WebFiori/Framework/Cli/Commands/SchedulerDaemonCommand.php
+++ b/WebFiori/Framework/Cli/Commands/SchedulerDaemonCommand.php
@@ -11,6 +11,7 @@
namespace WebFiori\Framework\Cli\Commands;
use WebFiori\Cli\Argument;
+use WebFiori\Cli\Attributes\SingleInstance;
use WebFiori\Cli\Command;
use WebFiori\Framework\Cli\CLIUtils;
use WebFiori\Framework\Scheduler\TasksManager;
@@ -30,6 +31,7 @@
*
* @author Ibrahim
*/
+#[SingleInstance]
class SchedulerDaemonCommand extends Command {
/**
* Creates a new instance of the command.
diff --git a/WebFiori/Framework/Cli/Commands/SchedulerRunCommand.php b/WebFiori/Framework/Cli/Commands/SchedulerRunCommand.php
index 9aa90dab0..7f54c018f 100644
--- a/WebFiori/Framework/Cli/Commands/SchedulerRunCommand.php
+++ b/WebFiori/Framework/Cli/Commands/SchedulerRunCommand.php
@@ -11,6 +11,7 @@
namespace WebFiori\Framework\Cli\Commands;
use WebFiori\Cli\Argument;
+use WebFiori\Cli\Attributes\SingleInstance;
use WebFiori\Cli\Command;
use WebFiori\Framework\Cli\CLIUtils;
use WebFiori\Framework\Scheduler\TasksManager;
@@ -23,6 +24,7 @@
*
* @author Ibrahim
*/
+#[SingleInstance]
class SchedulerRunCommand extends Command {
public function __construct() {
diff --git a/WebFiori/Framework/Cli/Commands/StepMigrationsCommand.php b/WebFiori/Framework/Cli/Commands/StepMigrationsCommand.php
new file mode 100644
index 000000000..2b078d124
--- /dev/null
+++ b/WebFiori/Framework/Cli/Commands/StepMigrationsCommand.php
@@ -0,0 +1,122 @@
+getConnection();
+
+ if ($connection === null) {
+ return 1;
+ }
+
+ $env = $this->getArgValue('--env') ?? 'dev';
+ $runner = new SchemaRunner($connection, $env);
+ $runner->discoverFromPath(APP_PATH.'Database'.DS.'Migrations', APP_DIR.'\\Database\\Migrations', true);
+
+ $pending = $runner->getPendingChanges(true);
+
+ if (empty($pending)) {
+ $this->info('No pending migrations.');
+
+ return 0;
+ }
+
+ $applied = 0;
+ $skipped = 0;
+
+ foreach ($pending as $item) {
+ $change = $item['change'];
+ $queries = $item['queries'];
+
+ $this->println('');
+ $this->println('Migration: '.$change->getName());
+ $this->println('');
+
+ if (!empty($queries)) {
+ $this->println('SQL:');
+
+ foreach ($queries as $q) {
+ $this->println(' '.$q);
+ }
+
+ $this->println('');
+ }
+
+ $action = $this->select('Action:', ['Apply', 'Skip', 'Quit'], 0);
+
+ if ($action === 'Apply') {
+ $runner->applyOne();
+ $applied++;
+ $this->success('Applied: '.$change->getName());
+ } else if ($action === 'Skip') {
+ $runner->skip($change);
+ $skipped++;
+ $this->warning('Skipped: '.$change->getName());
+ } else {
+ break;
+ }
+ }
+
+ $this->println('');
+ $this->info("Summary: $applied applied, $skipped skipped.");
+
+ return 0;
+ }
+
+ private function getConnection(): ?ConnectionInfo {
+ $connections = App::getConfig()->getDBConnections();
+
+ if (empty($connections)) {
+ $this->info('No database connections configured.');
+
+ return null;
+ }
+
+ $connectionName = $this->getArgValue('--connection');
+
+ if ($connectionName !== null) {
+ $connection = App::getConfig()->getDBConnection($connectionName);
+
+ if ($connection === null) {
+ $this->error("Connection '$connectionName' not found.");
+
+ return null;
+ }
+
+ return $connection;
+ }
+
+ return CLIUtils::getConnectionName($this);
+ }
+}
diff --git a/WebFiori/Framework/Cli/Commands/UpCommand.php b/WebFiori/Framework/Cli/Commands/UpCommand.php
index c860fef7d..73547ea87 100644
--- a/WebFiori/Framework/Cli/Commands/UpCommand.php
+++ b/WebFiori/Framework/Cli/Commands/UpCommand.php
@@ -11,11 +11,13 @@
*/
namespace WebFiori\Framework\Cli\Commands;
+use WebFiori\Cli\Attributes\Group;
use WebFiori\Cli\Command;
/**
* A command to bring the application out of maintenance mode.
*/
+#[Group('maintenance')]
class UpCommand extends Command {
public function __construct() {
parent::__construct('up', [], 'Bring the application out of maintenance mode.');
diff --git a/WebFiori/Framework/Cli/Commands/VersionCommand.php b/WebFiori/Framework/Cli/Commands/VersionCommand.php
index 3e9934c6f..d60a80574 100644
--- a/WebFiori/Framework/Cli/Commands/VersionCommand.php
+++ b/WebFiori/Framework/Cli/Commands/VersionCommand.php
@@ -10,12 +10,14 @@
*/
namespace WebFiori\Framework\Cli\Commands;
+use WebFiori\Cli\Attributes\Group;
use WebFiori\Cli\Command;
/**
* Description of VersionCommand
*
* @author Ibrahim
*/
+#[Group('other')]
class VersionCommand extends Command {
public function __construct() {
parent::__construct('v', [], 'Display framework version info.');
diff --git a/WebFiori/Framework/Cli/Commands/WHelpCommand.php b/WebFiori/Framework/Cli/Commands/WHelpCommand.php
index 594fd7737..f91d8a1d2 100644
--- a/WebFiori/Framework/Cli/Commands/WHelpCommand.php
+++ b/WebFiori/Framework/Cli/Commands/WHelpCommand.php
@@ -10,12 +10,14 @@
*/
namespace WebFiori\Framework\Cli\Commands;
+use WebFiori\Cli\Attributes\Group;
use WebFiori\Cli\Commands\HelpCommand;
/**
* Description of WHelpCommand
*
* @author Ibrahim
*/
+#[Group('other')]
class WHelpCommand extends HelpCommand {
public function exec() : int {
$argV = $this->getOwner()->getArgsVector();
diff --git a/WebFiori/Framework/Health/HealthCheck.php b/WebFiori/Framework/Health/HealthCheck.php
index 86b84bc2f..71a473616 100644
--- a/WebFiori/Framework/Health/HealthCheck.php
+++ b/WebFiori/Framework/Health/HealthCheck.php
@@ -21,6 +21,10 @@ class HealthCheck {
* @var array Registered checks indexed by name.
*/
private static array $checks = [];
+ /**
+ * @var array Callbacks to execute after runAll() completes.
+ */
+ private static array $afterAllCallbacks = [];
/**
* Register a health check.
*
@@ -68,17 +72,24 @@ public static function runAll(): array {
}
}
- return [
+ $aggregate = [
'status' => $allOk ? 'ok' : 'fail',
'timestamp' => date('c'),
'checks' => $results,
];
+
+ foreach (self::$afterAllCallbacks as $cb) {
+ $cb($aggregate);
+ }
+
+ return $aggregate;
}
/**
* Remove all registered checks.
*/
public static function reset(): void {
self::$checks = [];
+ self::$afterAllCallbacks = [];
}
/**
* Returns the number of registered checks.
@@ -88,4 +99,24 @@ public static function reset(): void {
public static function getCheckCount(): int {
return count(self::$checks);
}
+ /**
+ * Returns all registered checks.
+ *
+ * @return array Associative array keyed by check name. Values are
+ * HealthCheckInterface instances or callables.
+ */
+ public static function getChecks(): array {
+ return self::$checks;
+ }
+ /**
+ * Register a callback to execute after all checks complete.
+ *
+ * The callback receives the aggregate result array with 'status',
+ * 'timestamp', and 'checks' keys.
+ *
+ * @param callable $callback A function that accepts the aggregate results array.
+ */
+ public static function afterAll(callable $callback): void {
+ self::$afterAllCallbacks[] = $callback;
+ }
}
diff --git a/WebFiori/Framework/Middleware/CacheMiddleware.php b/WebFiori/Framework/Middleware/CacheMiddleware.php
index 58799c091..5afdd4b1a 100644
--- a/WebFiori/Framework/Middleware/CacheMiddleware.php
+++ b/WebFiori/Framework/Middleware/CacheMiddleware.php
@@ -24,7 +24,7 @@ public function __construct() {
$this->setPriority(50);
$this->addToGroups(['web']);
$this->fromCache = false;
- $this->cache = new Cache(new FileStorage());
+ $this->cache = new Cache(new FileStorage(sys_get_temp_dir().DS.'wf-cache'));
}
/**
* Checks if the response is loaded from the cache or caching must be performed.
@@ -101,14 +101,15 @@ public function before(Request $request, Response $response) {
* @return string
*/
public function getKey() : string {
- $key = Request::getUri()->getUri(true, true);
+ $request = \WebFiori\Framework\App::getRequest();
+ $key = $request->getUri()->getUri(true, true);
//Following steps are used to make cached response unique per user.
$session = SessionsManager::getActiveSession();
if ($session !== null) {
$key .= $session->getId();
}
- $authHeader = Request::getAuthHeader();
+ $authHeader = $request->getAuthHeader();
if ($authHeader !== null) {
$key .= $authHeader->getScheme().$authHeader->getCredentials();
}
diff --git a/WebFiori/Framework/Session/Session.php b/WebFiori/Framework/Session/Session.php
index 1a78806c3..89b416f71 100644
--- a/WebFiori/Framework/Session/Session.php
+++ b/WebFiori/Framework/Session/Session.php
@@ -668,9 +668,11 @@ public function start() {
if (!$this->isRunning()) {
$sessionStr = SessionsManager::getStorage()->read($this->getId());
- if ($this->getStatus() == SessionStatus::KILLED || $sessionStr === null || !$this->deserialize($sessionStr)) {
+ if ($this->getStatus() == SessionStatus::KILLED) {
$this->reGenerateID();
$this->initNewSessionVars();
+ } else if ($sessionStr === null || !$this->deserialize($sessionStr)) {
+ $this->initNewSessionVars();
} else {
$this->checkIfExpired();
}
diff --git a/WebFiori/Framework/Session/SessionManager.php b/WebFiori/Framework/Session/SessionManager.php
index d9a01e67a..726df6b59 100644
--- a/WebFiori/Framework/Session/SessionManager.php
+++ b/WebFiori/Framework/Session/SessionManager.php
@@ -385,6 +385,13 @@ public function start(string $sessionName, array $options = []): void {
if (!$this->hasSession($sessionName)) {
$options[SessionOption::NAME] = $sessionName;
+
+ $cookieId = $this->getSessionIDFromRequest($sessionName);
+
+ if ($cookieId !== false && !isset($options[SessionOption::SESSION_ID])) {
+ $options[SessionOption::SESSION_ID] = $cookieId;
+ }
+
$s = new Session($options);
$s->start();
$this->sessionsArr[] = $s;
diff --git a/WebFiori/Framework/Ui/ServerErrPage/server-err-head.php b/WebFiori/Framework/Ui/ServerErrPage/server-err-head.php
index 2ce17d151..8ba11001f 100644
--- a/WebFiori/Framework/Ui/ServerErrPage/server-err-head.php
+++ b/WebFiori/Framework/Ui/ServerErrPage/server-err-head.php
@@ -10,8 +10,8 @@
-
-
+
+
diff --git a/composer.json b/composer.json
index e8a52ba27..a98ee30b7 100644
--- a/composer.json
+++ b/composer.json
@@ -23,14 +23,14 @@
"ext-fileinfo": "*",
"ext-openssl": "*",
"webfiori/cache": "v3.0.*",
- "webfiori/http": "v5.0.*",
- "webfiori/file": "v2.0.*",
- "webfiori/jsonx": "v4.0.*",
+ "webfiori/http": "v6.0.*",
+ "webfiori/file": "v2.1.*",
+ "webfiori/jsonx": "v5.0.*",
"webfiori/ui": "v4.0.*",
"webfiori/collections": "v2.0.*",
"webfiori/database": "v2.2.*",
- "webfiori/cli": "v2.1.*",
- "webfiori/mailer": "v2.1.*",
+ "webfiori/cli": "v2.2.*",
+ "webfiori/mailer": "v2.2.*",
"webfiori/err": "v2.0.*",
"webfiori/log": "v1.0.*",
"webfiori/event": "v1.0.*",
diff --git a/tests/WebFiori/Framework/Tests/Cli/AddLangCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/AddLangCommandTest.php
index b60c0b550..be3a8d2f6 100644
--- a/tests/WebFiori/Framework/Tests/Cli/AddLangCommandTest.php
+++ b/tests/WebFiori/Framework/Tests/Cli/AddLangCommandTest.php
@@ -44,6 +44,7 @@ public function testAddLang00() {
"1: rtl\n",
"Success: Language added. Also, a class for the language is created at \"".APP_DIR."\Langs\" for that language.\n"
], $output);
+ clearstatcache();
$this->assertTrue(class_exists('\\App\\Langs\\Lang'.$langCode));
$this->removeClass('\\App\\Langs\\Lang'.$langCode);
Controller::getDriver()->initialize();
diff --git a/tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php
index 0be4ee0e6..f3d677793 100644
--- a/tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php
+++ b/tests/WebFiori/Framework/Tests/Cli/HelpCommandTest.php
@@ -16,21 +16,16 @@ public function test00() {
" command [arg1 arg2=\"val\" arg3...]\n\n",
"Global Arguments:\n",
" --ansi:[Optional] Force the use of ANSI output.\n",
+ " --no-color:[Optional] Disable ANSI colored output.\n",
+ " -q:[Optional] Quiet mode. Suppress non-critical output.\n",
+ " -v:[Optional] Verbose output.\n",
+ " -vv:[Optional] Debug output (most verbose).\n",
"Available Commands:\n",
- " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
- " v: Display framework version info.\n",
-
- " down: Put the application in maintenance mode.\n",
- " up: Bring the application out of maintenance mode.\n",
- " queue:status: Show pending and failed job counts.\n",
- " queue:retry: Retry or flush failed queue jobs.\n",
- " queue:work: Process queue jobs continuously.\n",
- " scheduler: Run tasks scheduler.\n",
- " scheduler:run: Run the tasks scheduler check.\n",
- " scheduler:daemon: Run the scheduler in a loop for a limited duration.\n",
+ " add:\n",
" add:db-connection: Add a database connection.\n",
" add:smtp-connection: Add an SMTP account.\n",
" add:lang: Add a website language.\n",
+ " create:\n",
" create:middleware: Create a new middleware class.\n",
" create:task: Create a new scheduler task class.\n",
" create:command: Create a new CLI command class.\n",
@@ -41,10 +36,10 @@ public function test00() {
" create:resource: Create a complete CRUD resource (entity, table, repository, service).\n",
" create:migration: Create a new database migration class.\n",
" create:seeder: Create a new database seeder class.\n",
-
-
-
-
+ " maintenance:\n",
+ " down: Put the application in maintenance mode.\n",
+ " up: Bring the application out of maintenance mode.\n",
+ " migrations:\n",
" migrations:run: Execute pending database migrations.\n",
" migrations:rollback: Rollback database migrations.\n",
" migrations:ini: Create migrations tracking table.\n",
@@ -52,6 +47,18 @@ public function test00() {
" migrations:status: Show migration status (applied and pending).\n",
" migrations:fresh: Rollback all migrations and run them fresh.\n",
" migrations:skip: Mark migrations as applied without executing them (baseline).\n",
+ " migrations:step: Interactively apply or skip migrations one at a time.\n",
+ " other:\n",
+ " help: Display CLI Help. To display help for specific command, use the argument \"--command\" with this command.\n",
+ " v: Display framework version info.\n",
+ " queue:\n",
+ " queue:status: Show pending and failed job counts.\n",
+ " queue:retry: Retry or flush failed queue jobs.\n",
+ " queue:work: Process queue jobs continuously.\n",
+ " scheduler:\n",
+ " scheduler:run: Run the tasks scheduler check.\n",
+ " scheduler:daemon: Run the scheduler in a loop for a limited duration.\n",
+ " scheduler: Run tasks scheduler.\n",
], $this->executeMultiCommand([
'help',
]));
diff --git a/tests/WebFiori/Framework/Tests/Cli/StepMigrationsCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/StepMigrationsCommandTest.php
new file mode 100644
index 000000000..2eaa68540
--- /dev/null
+++ b/tests/WebFiori/Framework/Tests/Cli/StepMigrationsCommandTest.php
@@ -0,0 +1,195 @@
+setupTestConnection();
+ $this->dropSchemaTable();
+ $this->cleanupMigrations();
+ }
+
+ protected function tearDown(): void {
+ $this->cleanupMigrations();
+ $this->dropSchemaTable();
+ App::getConfig()->removeAllDBConnections();
+ parent::tearDown();
+ }
+
+ /** @test */
+ public function testNoConnectionsConfigured() {
+ App::getConfig()->removeAllDBConnections();
+
+ $output = $this->executeMultiCommand([
+ StepMigrationsCommand::class
+ ]);
+
+ $this->assertEquals([
+ "Info: No database connections configured.\n"
+ ], $output);
+ $this->assertEquals(1, $this->getExitCode());
+ }
+
+ /** @test */
+ public function testNoPendingMigrations() {
+ $this->initMigrations();
+
+ $output = $this->executeMultiCommand([
+ StepMigrationsCommand::class,
+ '--connection' => 'test-connection'
+ ]);
+
+ $outputStr = implode('', $output);
+ $this->assertStringContainsString('No pending migrations.', $outputStr);
+ $this->assertEquals(0, $this->getExitCode());
+ }
+
+ /** @test */
+ public function testApplyOneMigration() {
+ $this->createTestMigration('StepApply1');
+ $this->initMigrations();
+
+ $output = $this->executeMultiCommand([
+ StepMigrationsCommand::class,
+ '--connection' => 'test-connection'
+ ], [
+ '0' // Select "Apply"
+ ]);
+
+ $outputStr = implode('', $output);
+ $this->assertStringContainsString('Applied:', $outputStr);
+ $this->assertStringContainsString('1 applied, 0 skipped', $outputStr);
+ $this->assertEquals(0, $this->getExitCode());
+ }
+
+ /** @test */
+ public function testSkipOneMigration() {
+ $this->createTestMigration('StepSkip1');
+ $this->initMigrations();
+
+ $output = $this->executeMultiCommand([
+ StepMigrationsCommand::class,
+ '--connection' => 'test-connection'
+ ], [
+ '1' // Select "Skip"
+ ]);
+
+ $outputStr = implode('', $output);
+ $this->assertStringContainsString('Skipped:', $outputStr);
+ $this->assertStringContainsString('0 applied, 1 skipped', $outputStr);
+ $this->assertEquals(0, $this->getExitCode());
+ }
+
+ /** @test */
+ public function testQuitBeforeProcessingAll() {
+ $this->createTestMigration('StepQuit1');
+ $this->createTestMigration('StepQuit2');
+ $this->initMigrations();
+
+ $output = $this->executeMultiCommand([
+ StepMigrationsCommand::class,
+ '--connection' => 'test-connection'
+ ], [
+ '0', // Apply first
+ '2' // Quit on second
+ ]);
+
+ $outputStr = implode('', $output);
+ $this->assertStringContainsString('Applied:', $outputStr);
+ $this->assertStringContainsString('1 applied, 0 skipped', $outputStr);
+ $this->assertEquals(0, $this->getExitCode());
+ }
+
+ /** @test */
+ public function testMixedApplyAndSkip() {
+ $this->createTestMigration('StepMix1');
+ $this->createTestMigration('StepMix2');
+ $this->initMigrations();
+
+ $output = $this->executeMultiCommand([
+ StepMigrationsCommand::class,
+ '--connection' => 'test-connection'
+ ], [
+ '0', // Apply first
+ '1' // Skip second
+ ]);
+
+ $outputStr = implode('', $output);
+ $this->assertStringContainsString('1 applied, 1 skipped', $outputStr);
+ $this->assertEquals(0, $this->getExitCode());
+ }
+
+ private function initMigrations(): void {
+ $this->executeMultiCommand([
+ 'WebFiori\\Framework\\Cli\\Commands\\InitMigrationsCommand',
+ '--connection' => 'test-connection'
+ ]);
+ }
+
+ private function createTestMigration(string $name): void {
+ $dir = APP_PATH.'Database'.DS.'Migrations';
+
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ $content = <<testConnection = new ConnectionInfo('mysql', 'root', MYSQL_ROOT_PASSWORD, 'testing_db', '127.0.0.1', 3306);
+ $this->testConnection->setName('test-connection');
+ App::getConfig()->addOrUpdateDBConnection($this->testConnection);
+ }
+
+ private function dropSchemaTable(): void {
+ try {
+ $connection = App::getConfig()->getDBConnection('test-connection');
+
+ if ($connection !== null) {
+ $runner = new SchemaRunner($connection);
+ $runner->dropSchemaTable();
+ }
+ } catch (\Throwable $e) {
+ // Ignore errors during cleanup
+ }
+ }
+}
diff --git a/tests/WebFiori/Framework/Tests/Health/HealthCheckTest.php b/tests/WebFiori/Framework/Tests/Health/HealthCheckTest.php
index 923015b0e..41706b13c 100644
--- a/tests/WebFiori/Framework/Tests/Health/HealthCheckTest.php
+++ b/tests/WebFiori/Framework/Tests/Health/HealthCheckTest.php
@@ -130,4 +130,62 @@ public function testResultToArray() {
$this->assertEquals('fail', $arr['status']);
$this->assertEquals('down', $arr['reason']);
}
+
+ /** @test */
+ public function testGetChecksReturnsRegisteredChecks() {
+ HealthCheck::reset();
+ HealthCheck::register(new PassingCheck());
+ HealthCheck::register('custom', fn() => HealthCheckResult::ok());
+
+ $checks = HealthCheck::getChecks();
+ $this->assertCount(2, $checks);
+ $this->assertArrayHasKey('passing', $checks);
+ $this->assertArrayHasKey('custom', $checks);
+ $this->assertInstanceOf(HealthCheckInterface::class, $checks['passing']);
+ $this->assertIsCallable($checks['custom']);
+ }
+
+ /** @test */
+ public function testAfterAllCallbackReceivesAggregateResults() {
+ HealthCheck::reset();
+ HealthCheck::register(new PassingCheck());
+ HealthCheck::register(new FailingCheck());
+
+ $received = null;
+ HealthCheck::afterAll(function (array $results) use (&$received) {
+ $received = $results;
+ });
+
+ $result = HealthCheck::runAll();
+
+ $this->assertNotNull($received);
+ $this->assertEquals($result, $received);
+ $this->assertEquals('fail', $received['status']);
+ $this->assertArrayHasKey('passing', $received['checks']);
+ $this->assertArrayHasKey('failing', $received['checks']);
+ }
+
+ /** @test */
+ public function testAfterAllMultipleCallbacks() {
+ HealthCheck::reset();
+ HealthCheck::register(new PassingCheck());
+
+ $count = 0;
+ HealthCheck::afterAll(function () use (&$count) { $count++; });
+ HealthCheck::afterAll(function () use (&$count) { $count++; });
+
+ HealthCheck::runAll();
+ $this->assertEquals(2, $count);
+ }
+
+ /** @test */
+ public function testResetClearsAfterAllCallbacks() {
+ HealthCheck::reset();
+ $called = false;
+ HealthCheck::afterAll(function () use (&$called) { $called = true; });
+ HealthCheck::reset();
+ HealthCheck::register(new PassingCheck());
+ HealthCheck::runAll();
+ $this->assertFalse($called);
+ }
}
diff --git a/tests/WebFiori/Framework/Tests/Middleware/CacheMiddlewareTest.php b/tests/WebFiori/Framework/Tests/Middleware/CacheMiddlewareTest.php
new file mode 100644
index 000000000..ef2d4ab7e
--- /dev/null
+++ b/tests/WebFiori/Framework/Tests/Middleware/CacheMiddlewareTest.php
@@ -0,0 +1,66 @@
+assertEquals('cache', $mw->getName());
+ $this->assertEquals(50, $mw->getPriority());
+ $this->assertContains('web', $mw->getGroups());
+ }
+
+ /** @test */
+ public function testGetKeyWithoutSession() {
+ $mw = new CacheMiddleware();
+ $key = $mw->getKey();
+ $this->assertNotEmpty($key);
+ $this->assertIsString($key);
+ }
+
+ /** @test */
+ public function testGetKeyWithSession() {
+ SessionsManager::start('wf-session');
+ $mw = new CacheMiddleware();
+ $key = $mw->getKey();
+ $session = SessionsManager::getActiveSession();
+ $this->assertStringContainsString($session->getId(), $key);
+ }
+
+ /** @test */
+ public function testBeforeWithNoCache() {
+ $mw = new CacheMiddleware();
+ $request = new Request();
+ $response = new Response();
+
+ $mw->before($request, $response);
+ $this->assertEquals(200, $response->getCode());
+ $this->assertEmpty($response->getBody());
+ }
+
+ /** @test */
+ public function testAfterSendDoesNothing() {
+ $mw = new CacheMiddleware();
+ $request = new Request();
+ $response = new Response();
+
+ $mw->afterSend($request, $response);
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/WebFiori/Framework/Tests/Middleware/StartSessionMiddlewareTest.php b/tests/WebFiori/Framework/Tests/Middleware/StartSessionMiddlewareTest.php
new file mode 100644
index 000000000..886c572ec
--- /dev/null
+++ b/tests/WebFiori/Framework/Tests/Middleware/StartSessionMiddlewareTest.php
@@ -0,0 +1,111 @@
+assertEquals('start-session', $mw->getName());
+ $this->assertEquals(PHP_INT_MAX, $mw->getPriority());
+ $this->assertEquals('wf-session', $mw->getSessionName());
+ $this->assertEquals([], $mw->getSessionOptions());
+ $this->assertContains('web', $mw->getGroups());
+ }
+
+ /** @test */
+ public function testSetSessionName() {
+ $mw = new StartSessionMiddleware();
+ $mw->setSessionName('my-session');
+ $this->assertEquals('my-session', $mw->getSessionName());
+ }
+
+ /** @test */
+ public function testSetSessionOptions() {
+ $mw = new StartSessionMiddleware();
+ $mw->setSessionOptions(['duration' => 3600]);
+ $this->assertEquals(['duration' => 3600], $mw->getSessionOptions());
+ }
+
+ /** @test */
+ public function testBeforeStartsSession() {
+ $mw = new StartSessionMiddleware();
+ $request = new Request();
+ $response = new Response();
+
+ $this->assertNull(SessionsManager::getActiveSession());
+ $mw->before($request, $response);
+ $active = SessionsManager::getActiveSession();
+ $this->assertNotNull($active);
+ $this->assertEquals('wf-session', $active->getName());
+ $this->assertTrue($active->isRunning());
+ }
+
+ /** @test */
+ public function testBeforeWithCustomName() {
+ $mw = new StartSessionMiddleware();
+ $mw->setSessionName('custom-session');
+ $request = new Request();
+ $response = new Response();
+
+ $mw->before($request, $response);
+ $active = SessionsManager::getActiveSession();
+ $this->assertNotNull($active);
+ $this->assertEquals('custom-session', $active->getName());
+ }
+
+ /** @test */
+ public function testAfterAddsCookieHeaders() {
+ $mw = new StartSessionMiddleware();
+ $request = new Request();
+ $response = new Response();
+
+ $mw->before($request, $response);
+ $mw->after($request, $response);
+
+ $headers = $response->getHeaders();
+ $hasCookie = false;
+
+ foreach ($headers as $h) {
+ if (strtolower($h->getName()) === 'set-cookie') {
+ $hasCookie = true;
+ break;
+ }
+ }
+
+ $this->assertTrue($hasCookie, 'Response should have set-cookie header after middleware after()');
+ }
+
+ /** @test */
+ public function testAfterSendValidatesStorage() {
+ $mw = new StartSessionMiddleware();
+ $request = new Request();
+ $response = new Response();
+
+ $mw->before($request, $response);
+ // Should not throw
+ $mw->afterSend($request, $response);
+ $this->assertTrue(true);
+ }
+
+ /** @test */
+ public function testGetManager() {
+ $mw = new StartSessionMiddleware();
+ $this->assertNotNull($mw->getManager());
+ }
+}
diff --git a/tests/WebFiori/Framework/Tests/Session/SessionsManagerTest.php b/tests/WebFiori/Framework/Tests/Session/SessionsManagerTest.php
index 0cba2fe95..bf97ea383 100644
--- a/tests/WebFiori/Framework/Tests/Session/SessionsManagerTest.php
+++ b/tests/WebFiori/Framework/Tests/Session/SessionsManagerTest.php
@@ -567,4 +567,37 @@ public function testFacadePauseAll() {
public function testGetSessionIDFromCookieNotSet() {
$this->assertFalse(SessionsManager::getSessionIDFromCookie('nonexistent'));
}
+
+ /**
+ * @test
+ * Verifies bug #389: When a session cookie exists but storage is empty,
+ * the framework should reuse the cookie ID instead of generating a new one.
+ */
+ public function testReuseCookieIdWhenStorageEmpty() {
+ SessionsManager::reset();
+ App::getRequest()->setRequestMethod('GET');
+
+ // Simulate: browser has a session cookie with ID 'old-session-id-abc'
+ $sessionName = 'wf-test-session';
+ $_GET[$sessionName] = 'old-session-id-abc';
+
+ // Storage is empty (no session data for this ID — simulates cleared storage)
+ // Start the session
+ SessionsManager::start($sessionName);
+
+ $active = SessionsManager::getActiveSession();
+ $this->assertNotNull($active);
+
+ // BUG: The session should reuse 'old-session-id-abc' from the cookie,
+ // not generate a brand new ID
+ $this->assertEquals(
+ 'old-session-id-abc',
+ $active->getId(),
+ 'Session ID should match the cookie value when storage is empty (bug #389)'
+ );
+
+ // Cleanup
+ SessionsManager::reset();
+ unset($_GET[$sessionName]);
+ }
}
diff --git a/tests/WebFiori/Framework/Tests/Theme/ThemeTest.php b/tests/WebFiori/Framework/Tests/Theme/ThemeTest.php
index 91dd8d865..9967c807c 100644
--- a/tests/WebFiori/Framework/Tests/Theme/ThemeTest.php
+++ b/tests/WebFiori/Framework/Tests/Theme/ThemeTest.php
@@ -155,14 +155,11 @@ public function testRegisterNonThemeClass() {
* @test
*/
public function testRegisterDuplicate() {
- $themes = ThemeManager::getRegisteredThemes();
- if (count($themes) > 0) {
- $firstTheme = array_values($themes)[0];
- $this->expectException(\WebFiori\Framework\Exceptions\NoSuchThemeException::class);
- ThemeManager::register($firstTheme);
- } else {
- $this->markTestSkipped('No themes registered');
+ if (!ThemeManager::isThemeRegistered('New Super Theme')) {
+ ThemeManager::register(new NewFTestTheme());
}
+ $this->expectException(\WebFiori\Framework\Exceptions\NoSuchThemeException::class);
+ ThemeManager::register(new NewFTestTheme());
}
/**
* @test