diff --git a/_config.php b/_config.php index e69de29..f854753 100644 --- a/_config.php +++ b/_config.php @@ -0,0 +1,4 @@ +get('TestSessionEnvironment')->loadFromFile(); \ No newline at end of file diff --git a/_config/_config.yml b/_config/_config.yml index b28182d..b7aee78 100644 --- a/_config/_config.yml +++ b/_config/_config.yml @@ -8,4 +8,9 @@ Injector: - '%$TestSessionRequestFilter' Member: extensions: - - TestSessionMemberExtension \ No newline at end of file + - TestSessionMemberExtension +--- +Name: testsession-setup +--- +TestSessionEnvironment: + test_state_file: TESTS_RUNNING.json \ No newline at end of file diff --git a/code/TestSessionController.php b/code/TestSessionController.php index 2bcac1e..737aa6e 100644 --- a/code/TestSessionController.php +++ b/code/TestSessionController.php @@ -41,7 +41,7 @@ class TestSessionController extends Controller { } public function index() { - if(Session::get('testsession.started')) { + if(Injector::inst()->get('TestSessionEnvironment')->isRunningTests()) { return $this->renderWith('TestSession_inprogress'); } else { return $this->renderWith('TestSession_start'); @@ -49,13 +49,29 @@ class TestSessionController extends Controller { } /** - * Start a test session. + * Start a test session. If you wish to extend how the test session is started (and add additional test state), + * then take a look at {@link TestSessionEnvironment::startTestSession()} and + * {@link TestSessionEnvironment::applyState()} to see the extension points. */ public function start() { - $this->extend('onBeforeStart'); $params = $this->request->requestVars(); - $this->setState($params); - $this->extend('onAfterStart'); + + // Convert datetime from form object into a single string + $params = $this->fixDatetimeFormField($params); + + // Remove unnecessary items of form-specific data from being saved in the test session + $params = array_diff_key( + $params, + array( + 'action_set' => true, + 'action_start' => true, + 'SecurityID' => true, + 'url' => true, + 'flush' => true, + ) + ); + + Injector::inst()->get('TestSessionEnvironment')->startTestSession($params); return $this->renderWith('TestSession_inprogress'); } @@ -110,6 +126,8 @@ class TestSessionController extends Controller { } protected function getBaseFields() { + $testState = Injector::inst()->get('TestSessionEnvironment')->getState(); + $fields = new FieldList( (new TextField('fixture', 'Fixture YAML file path')) ->setAttribute('placeholder', 'Example: framework/tests/security/MemberTest.yml'), @@ -123,7 +141,7 @@ class TestSessionController extends Controller { $datetimeField->getTimeField() ->setConfig('timeformat', 'HH:mm:ss') ->setAttribute('placeholder', 'Time (HH:mm:ss)'); - $datetimeField->setValue(Session::get('testsession.datetime')); + $datetimeField->setValue((isset($testState->datetime) ? $testState->datetime : null)); $this->extend('updateBaseFields', $fields); @@ -131,32 +149,46 @@ class TestSessionController extends Controller { } public function DatabaseName() { - // Workaround for bug in Cookie::get(), fixed in 3.1-rc1 - if(self::$alternative_database_name != -1) { - return self::$alternative_database_name; - } else if ($dbname = DB::get_alternative_database_name()) { - return $dbname; - } else { - $db = DB::getConn(); - if(method_exists($db, 'currentDatabase')) return $db->currentDatabase(); - } + $db = DB::getConn(); + if(method_exists($db, 'currentDatabase')) return $db->currentDatabase(); } + /** + * Updates an in-progress {@link TestSessionEnvironment} object with new details. This could be loading in new + * fixtures, setting the mocked date to another value etc. + * + * @return HTMLText Rendered Template + * @throws LogicException + */ public function set() { - if(!Session::get('testsession.started')) { + if(!Injector::inst()->get('TestSessionEnvironment')->isRunningTests()) { throw new LogicException("No test session in progress."); } $params = $this->request->requestVars(); - $this->extend('onBeforeSet', $params); - $this->setState($params); - $this->extend('onAfterSet'); + + // Convert datetime from form object into a single string + $params = $this->fixDatetimeFormField($params); + + // Remove unnecessary items of form-specific data from being saved in the test session + $params = array_diff_key( + $params, + array( + 'action_set' => true, + 'action_start' => true, + 'SecurityID' => true, + 'url' => true, + 'flush' => true, + ) + ); + + Injector::inst()->get('TestSessionEnvironment')->updateTestSession($params); return $this->renderWith('TestSession_inprogress'); } public function clear() { - if(!Session::get('testsession.started')) { + if(!Injector::inst()->get('TestSessionEnvironment')->isRunningTests()) { throw new LogicException("No test session in progress."); } @@ -174,50 +206,22 @@ class TestSessionController extends Controller { return "Cleared database and test state"; } - + + /** + * As with {@link self::start()}, if you want to extend the functionality of this, then look at + * {@link TestSessionEnvironent::endTestSession()} as the extension points have moved to there now that the logic + * is there. + */ public function end() { - if(!Session::get('testsession.started')) { + if(!Injector::inst()->get('TestSessionEnvironment')->isRunningTests()) { throw new LogicException("No test session in progress."); } - $this->extend('onBeforeEnd'); - - if(SapphireTest::using_temp_db()) { - SapphireTest::kill_temp_db(); - DB::set_alternative_database_name(null); - // Workaround for bug in Cookie::get(), fixed in 3.1-rc1 - self::$alternative_database_name = null; - } - - Session::clear('testsession'); - - $this->extend('onAfterEnd'); + Injector::inst()->get('TestSessionEnvironment')->endTestSession(); return $this->renderWith('TestSession_end'); } - protected function loadFixtureIntoDb($fixtureFile) { - $realFile = realpath(BASE_PATH.'/'.$fixtureFile); - $baseDir = realpath(Director::baseFolder()); - if(!$realFile || !file_exists($realFile)) { - throw new LogicException("Fixture file doesn't exist"); - } else if(substr($realFile,0,strlen($baseDir)) != $baseDir) { - throw new LogicException("Fixture file must be inside $baseDir"); - } else if(substr($realFile,-4) != '.yml') { - throw new LogicException("Fixture file must be a .yml file"); - } else if(!preg_match('/^([^\/.][^\/]+)\/tests\//', $fixtureFile)) { - throw new LogicException("Fixture file must be inside the tests subfolder of one of your modules."); - } - - $factory = Injector::inst()->create('FixtureFactory'); - $fixture = Injector::inst()->create('YamlFixture', $fixtureFile); - $fixture->writeInto($factory); - - Session::add_to_array('testsession.fixtures', $fixtureFile); - - return $fixture; - } - /** * @return boolean */ @@ -226,131 +230,19 @@ class TestSessionController extends Controller { } public function setState($data) { - // Filter keys - $data = array_diff_key( - $data, - array( - 'action_set' => true, - 'action_start' => true, - 'SecurityID' => true, - 'url' => true, - 'flush' => true, - ) - ); - - // Database - if( - !Session::get('testsession.started') - && (@$data['createDatabase'] || @$data['database']) - ) { - $dbName = (isset($data['database'])) ? $data['database'] : null; - if($dbName) { - $dbExists = (bool)DB::query( - sprintf("SHOW DATABASES LIKE '%s'", Convert::raw2sql($dbName)) - )->value(); - } else { - $dbExists = false; - } - if(!$dbExists) { - // Create a new one with a randomized name - $dbName = SapphireTest::create_temp_db(); - } - - // Set existing one, assumes it already has been created - $prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_'; - $pattern = strtolower(sprintf('#^%stmpdb\d{7}#', $prefix)); - if(!preg_match($pattern, $dbName)) { - throw new InvalidArgumentException("Invalid database name format"); - } - DB::set_alternative_database_name($dbName); - // Workaround for bug in Cookie::get(), fixed in 3.1-rc1 - self::$alternative_database_name = $dbName; - - // Database name is set in cookie (next request), ensure its available on this request already - global $databaseConfig; - DB::connect(array_merge($databaseConfig, array('database' => $dbName))); - if(isset($data['database'])) unset($data['database']); - - // Import database template if required - if(isset($data['createDatabaseTemplate']) && $data['createDatabaseTemplate']) { - $sql = file_get_contents($data['createDatabaseTemplate']); - // Split into individual query commands, removing comments - $sqlCmds = array_filter( - preg_split('/\s*;\s*/', - preg_replace(array('/^$\n/m', '/^(\/|#).*$\n/m'), '', $sql) - ) - ); - - // Execute each query - foreach($sqlCmds as $sqlCmd) { - DB::query($sqlCmd); - } - - // In case the dump involved CREATE TABLE commands, we need to ensure - // the schema is still up to date - $dbAdmin = new DatabaseAdmin(); - $dbAdmin->doBuild(true /*quiet*/, false /*populate*/); - } - } - - // Fixtures - $fixtureFile = (isset($data['fixture'])) ? $data['fixture'] : null; - if($fixtureFile) { - $this->loadFixtureIntoDb($fixtureFile); - unset($data['fixture']); - } - - // Mailer - $mailer = (isset($data['mailer'])) ? $data['mailer'] : null; - if($mailer) { - if(!class_exists($mailer) || !is_subclass_of($mailer, 'Mailer')) { - throw new InvalidArgumentException(sprintf( - 'Class "%s" is not a valid class, or subclass of Mailer', - $mailer - )); - } - Session::set('testsession.mailer', $mailer); - unset($data['mailer']); - } - - // Date and time - if(@$data['datetime']['date']) { - require_once 'Zend/Date.php'; - // Convert DatetimeField format - $datetime = $data['datetime']['date']; - $datetime .= ' '; - $datetime .= (@$data['datetime']['time']) ? $data['datetime']['time'] : '00:00:00'; - if(!Zend_Date::isDate($datetime, 'yyyy-MM-dd HH:mm:ss')) { - throw new LogicException(sprintf( - 'Invalid date format "%s", use yyyy-MM-dd HH:mm:ss', - $datetime - )); - } - Session::set('testsession.datetime', $datetime); - unset($data['datetime']); - } else { - unset($data['datetime']); - } - - // Set all other keys without special handling - if($data) foreach($data as $k => $v) { - Session::set('testsession.' . $k, $v); - } - - Session::set('testsession.started', true); + Deprecation::notice('3.1', 'TestSessionController::setState() is no longer used, please use ' + . 'TestSessionEnvironment instead.'); } /** * @return ArrayList */ public function getState() { + $stateObj = Injector::inst()->get('TestSessionEnvironment')->getState(); $state = array(); - $state[] = new ArrayData(array( - 'Name' => 'Database', - 'Value' => DB::getConn()->currentDatabase(), - )); - $sessionStates = Session::get('testsession'); - if($sessionStates) foreach($sessionStates as $k => $v) { + + // Convert the stdObject of state into ArrayData + foreach($stateObj as $k => $v) { $state[] = new ArrayData(array( 'Name' => $k, 'Value' => var_export($v, true) @@ -390,4 +282,22 @@ class TestSessionController extends Controller { return $templates; } + /** + * @param $params array The form fields as passed through from ->start() or ->set() + * @return array The form fields, after fixing the datetime field if necessary + */ + private function fixDatetimeFormField($params) { + if(isset($params['datetime']) && is_array($params['datetime']) && !empty($params['datetime']['date'])) { + // Convert DatetimeField format from array into string + $datetime = $params['datetime']['date']; + $datetime .= ' '; + $datetime .= (@$params['datetime']['time']) ? $params['datetime']['time'] : '00:00:00'; + $params['datetime'] = $datetime; + } else if(isset($params['datetime']) && empty($params['datetime']['date'])) { + unset($params['datetime']); // No datetime, so remove the param entirely + } + + return $params; + } + } \ No newline at end of file diff --git a/code/TestSessionEnvironment.php b/code/TestSessionEnvironment.php new file mode 100644 index 0000000..aef9290 --- /dev/null +++ b/code/TestSessionEnvironment.php @@ -0,0 +1,338 @@ +test_state_file + */ + public function isRunningTests() { + return(file_exists(Director::getAbsFile($this->config()->test_state_file))); + } + + /** + * Creates a temp database, sets up any extra requirements, and writes the state file. The database will be + * connected to as part of {@link self::applyState()}, so if you're continuing script execution after calling this + * method, be aware that the database will be different - so various things may break (e.g. administrator logins + * using the SS_DEFAULT_USERNAME / SS_DEFAULT_PASSWORD constants). + * + * If something isn't explicitly handled here, and needs special handling, then it should be taken care of by an + * extension to TestSessionEnvironment. You can either extend onBeforeStartTestSession() or + * onAfterStartTestSession(). Alternatively, for more fine-grained control, you can also extend + * onBeforeApplyState() and onAfterApplyState(). See the {@link self::applyState()} method for more. + * + * @param array $state An array of test state options to write. + */ + public function startTestSession($state) { + $this->extend('onBeforeStartTestSession', $state); + + // Convert to JSON and back so we can share the appleState() code between this and ->loadFromFile() + $jason = json_encode($state, JSON_FORCE_OBJECT); + $state = json_decode($jason); + + $this->applyState($state); + $this->persistState(); + + $this->extend('onAfterStartTestSession'); + } + + public function updateTestSession($state) { + $this->extend('onBeforeUpdateTestSession', $state); + + // Convert to JSON and back so we can share the appleState() code between this and ->loadFromFile() + $jason = json_encode($state, JSON_FORCE_OBJECT); + $state = json_decode($jason); + + $this->applyState($state); + $this->persistState(); + + $this->extend('onAfterUpdateTestSession'); + } + + /** + * Assumes the database has already been created in startTestSession(), as this method can be called from + * _config.php where we don't yet have a DB connection. + * + * Does not persist the state to the filesystem, {@see self::persistState()}. + * + * You can extend this by creating an Extension object and implementing either onBeforeApplyState() or + * onAfterApplyState() to add your own test state handling in. + * + * @throws LogicException + * @throws InvalidArgumentException + */ + public function applyState($state) { + global $databaseConfig; + + $this->extend('onBeforeApplyState', $state); + + // Load existing state from $this->state into $state, if there is any + if($this->state) { + foreach($this->state as $k => $v) { + if(!isset($state->$k)) $state->$k = $v; // Don't overwrite stuff in $state, as that's the new state + } + } + + if(!DB::getConn()) { + // No connection, so try and connect to tmpdb if it exists + if(isset($state->database)) { + $this->oldDatabaseName = $databaseConfig['database']; + $databaseConfig['database'] = $state->database; + } + + // Connect to database + DB::connect($databaseConfig); + } else { + // We've already connected to the database, do a fast check to see what database we're currently using + $db = DB::query("SELECT DATABASE()")->value(); + if(isset($state->database) && $db != $state->database) { + $this->oldDatabaseName = $databaseConfig['database']; + $databaseConfig['database'] = $state->database; + DB::connect($databaseConfig); + } + } + + // Database + if(!$this->isRunningTests() && (@$state->createDatabase || @$state->database)) { + $dbName = (isset($state->database)) ? $state->database : null; + + if($dbName) { + $dbExists = (bool)DB::query( + sprintf("SHOW DATABASES LIKE '%s'", Convert::raw2sql($dbName)) + )->value(); + } else { + $dbExists = false; + } + + if(!$dbExists) { + // Create a new one with a randomized name + $dbName = SapphireTest::create_temp_db(); + + $state->database = $dbName; // In case it's changed by the call to SapphireTest::create_temp_db(); + + // Set existing one, assumes it already has been created + $prefix = defined('SS_DATABASE_PREFIX') ? SS_DATABASE_PREFIX : 'ss_'; + $pattern = strtolower(sprintf('#^%stmpdb\d{7}#', $prefix)); + if(!preg_match($pattern, $dbName)) { + throw new InvalidArgumentException("Invalid database name format"); + } + + $this->oldDatabaseName = $databaseConfig['database']; + $databaseConfig['database'] = $dbName; // Instead of calling DB::set_alternative_db_name(); + + // Connect to the new database, overwriting the old DB connection (if any) + DB::connect($databaseConfig); + } + + // Import database template if required + if(isset($state->createDatabaseTemplate) && $state->createDatabaseTemplate) { + $sql = file_get_contents($state->createDatabaseTemplate); + // Split into individual query commands, removing comments + $sqlCmds = array_filter( + preg_split('/\s*;\s*/', + preg_replace(array('/^$\n/m', '/^(\/|#).*$\n/m'), '', $sql) + ) + ); + + // Execute each query + foreach($sqlCmds as $sqlCmd) { + DB::query($sqlCmd); + } + + // In case the dump involved CREATE TABLE commands, we need to ensure + // the schema is still up to date + $dbAdmin = new DatabaseAdmin(); + $dbAdmin->doBuild(true /*quiet*/, false /*populate*/); + } + + if(isset($state->createDatabase)) unset($state->createDatabase); + } + + // Fixtures + $fixtureFile = (isset($state->fixture)) ? $state->fixture : null; + if($fixtureFile) { + $this->loadFixtureIntoDb($fixtureFile); + unset($state->fixture); // Only insert the fixture(s) once, not every time we call this method + } + + // Mailer + $mailer = (isset($state->mailer)) ? $state->mailer : null; + if($mailer) { + if(!class_exists($mailer) || !is_subclass_of($mailer, 'Mailer')) { + throw new InvalidArgumentException(sprintf( + 'Class "%s" is not a valid class, or subclass of Mailer', + $mailer + )); + } + } + + // Date and time + if(isset($state->datetime)) { + require_once 'Zend/Date.php'; + // Convert DatetimeField format + if(!Zend_Date::isDate($state->datetime, 'yyyy-MM-dd HH:mm:ss')) { + throw new LogicException(sprintf( + 'Invalid date format "%s", use yyyy-MM-dd HH:mm:ss', + $state->datetime + )); + } + } + + $this->state = $state; + + $this->extend('onAfterApplyState'); + } + + public function loadFromFile() { + if($this->isRunningTests()) { + try { + $contents = file_get_contents(Director::getAbsFile($this->config()->test_state_file)); + $jason = json_decode($contents); + + if(!isset($jason->database)) { + throw new \LogicException('The test session file (' + . Director::getAbsFile($this->config()->test_state_file) . ') doesn\'t contain a database name.'); + } + + $this->applyState($jason); + } catch(Exception $e) { + throw new \Exception("A test session appears to be in progress, but we can't retrieve the details. " + . "Try removing the " . Director::getAbsFile($this->config()->test_state_file) . " file. Inner " + . "error: " . $e->getMessage()); + } + } + } + + /** + * Writes $this->state JSON object into the $this->config()->test_state_file file. + */ + private function persistState() { + file_put_contents(Director::getAbsFile($this->config()->test_state_file), json_encode($this->state)); + } + + private function removeStateFile() { + if(file_exists(Director::getAbsFile($this->config()->test_state_file))) { + if(!unlink(Director::getAbsFile($this->config()->test_state_file))) { + throw new \Exception('Unable to remove the testsession state file, please remove it manually. File ' + . 'path: ' . Director::getAbsFile($this->config()->test_state_file)); + } + } + } + + /** + * Cleans up the test session state by restoring the normal database connect (for the rest of this request, if any) + * and removes the {@link self::$test_state_file} so that future requests don't use this test state. + * + * Can be extended by implementing either onBeforeEndTestSession() or onAfterEndTestSession(). + * + * This should implement itself cleanly in case it is called twice (e.g. don't throw errors when the state file + * doesn't exist anymore because it's already been cleaned up etc.) This is because during behat test runs where + * a queueing system (e.g. silverstripe-resque) is used, the behat module may call this first, and then the forked + * worker will call it as well - but there is only one state file that is created. + */ + public function endTestSession() { + $this->extend('onBeforeEndTestSession'); + + if(SapphireTest::using_temp_db()) { + $this->resetDatabaseName(); + } + + $this->removeStateFile(); + + $this->extend('onAfterEndTestSession'); + } + + /** + * Loads a YAML fixture into the database as part of the {@link TestSessionController}. + * + * @param string $fixtureFile The .yml file to load + * @return FixtureFactory The loaded fixture + * @throws LogicException + */ + protected function loadFixtureIntoDb($fixtureFile) { + $realFile = realpath(BASE_PATH.'/'.$fixtureFile); + $baseDir = realpath(Director::baseFolder()); + if(!$realFile || !file_exists($realFile)) { + throw new LogicException("Fixture file doesn't exist"); + } else if(substr($realFile,0,strlen($baseDir)) != $baseDir) { + throw new LogicException("Fixture file must be inside $baseDir"); + } else if(substr($realFile,-4) != '.yml') { + throw new LogicException("Fixture file must be a .yml file"); + } else if(!preg_match('/^([^\/.][^\/]+)\/tests\//', $fixtureFile)) { + throw new LogicException("Fixture file must be inside the tests subfolder of one of your modules."); + } + + $factory = Injector::inst()->create('FixtureFactory'); + $fixture = Injector::inst()->create('YamlFixture', $fixtureFile); + $fixture->writeInto($factory); + + $this->state->fixtures[] = $fixtureFile; + + return $fixture; + } + + /** + * Reset the database connection to use the original database. Called by {@link self::endTestSession()}. + */ + public function resetDatabaseName() { + global $databaseConfig; + + $databaseConfig['database'] = $this->oldDatabaseName; + + DB::connect($databaseConfig); + } + + /** + * @return stdClass Data as taken from the JSON object in {@link self::loadFromFile()} + */ + public function getState() { + return $this->state; + } +} \ No newline at end of file diff --git a/code/TestSessionRequestFilter.php b/code/TestSessionRequestFilter.php new file mode 100644 index 0000000..db8bde5 --- /dev/null +++ b/code/TestSessionRequestFilter.php @@ -0,0 +1,42 @@ +get('TestSessionEnvironment')->isRunningTests()) return; + + $testState = Injector::inst()->get('TestSessionEnvironment')->getState(); + + // Date and time + if(isset($testState->datetime)) { + SS_Datetime::set_mock_now($testState->datetime); + } + + // Register mailer + if(isset($testState->mailer)) { + $mailer = $testState->mailer; + Email::set_mailer(new $mailer()); + Config::inst()->update("Email","send_all_emails_to", null); + } + + // Allows inclusion of a PHP file, usually with procedural commands + // to set up required test state. The file can be generated + // through {@link TestSessionStubCodeWriter}, and the session state + // set through {@link TestSessionController->set()} and the + // 'testsession.stubfile' state parameter. + if(isset($testState->stubfile)) { + $file = $testState->stubfile; + if(!Director::isLive() && $file && file_exists($file)) { + // Connect to the database so the included code can interact with it + global $databaseConfig; + if ($databaseConfig) DB::connect($databaseConfig); + include_once($file); + } + } + } + + public function postRequest() { + } +} \ No newline at end of file diff --git a/code/TestSesssionRequestFilter.php b/code/TestSesssionRequestFilter.php deleted file mode 100644 index 4e7863e..0000000 --- a/code/TestSesssionRequestFilter.php +++ /dev/null @@ -1,37 +0,0 @@ -inst_get('testsession.started')) return; - - // Date and time - if($datetime = $session->inst_get('testsession.datetime')) { - SS_Datetime::set_mock_now($datetime); - } - - // Register mailer - if($mailer = $session->inst_get('testsession.mailer')) { - Email::set_mailer(new $mailer()); - Config::inst()->update("Email","send_all_emails_to", null); - } - - // Allows inclusion of a PHP file, usually with procedural commands - // to set up required test state. The file can be generated - // through {@link TestSessionStubCodeWriter}, and the session state - // set through {@link TestSessionController->set()} and the - // 'testsessio.stubfile' state parameter. - $file = $session->inst_get('testsession.stubfile'); - if(!Director::isLive() && $file && file_exists($file)) { - // Connect to the database so the included code can interact with it - global $databaseConfig; - if ($databaseConfig) DB::connect($databaseConfig); - include_once($file); - } - } - - public function postRequest() { - } -} \ No newline at end of file