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