1<?php 2// This file is part of Moodle - http://moodle.org/ 3// 4// Moodle is free software: you can redistribute it and/or modify 5// it under the terms of the GNU General Public License as published by 6// the Free Software Foundation, either version 3 of the License, or 7// (at your option) any later version. 8// 9// Moodle is distributed in the hope that it will be useful, 10// but WITHOUT ANY WARRANTY; without even the implied warranty of 11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12// GNU General Public License for more details. 13// 14// You should have received a copy of the GNU General Public License 15// along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17/** 18 * Advanced test case. 19 * 20 * @package core 21 * @category phpunit 22 * @copyright 2012 Petr Skoda {@link http://skodak.org} 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 */ 25 26 27/** 28 * Advanced PHPUnit test case customised for Moodle. 29 * 30 * @package core 31 * @category phpunit 32 * @copyright 2012 Petr Skoda {@link http://skodak.org} 33 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 34 */ 35abstract class advanced_testcase extends base_testcase { 36 /** @var bool automatically reset everything? null means log changes */ 37 private $resetAfterTest; 38 39 /** @var moodle_transaction */ 40 private $testdbtransaction; 41 42 /** @var int timestamp used for current time asserts */ 43 private $currenttimestart; 44 45 /** 46 * Constructs a test case with the given name. 47 * 48 * Note: use setUp() or setUpBeforeClass() in your test cases. 49 * 50 * @param string $name 51 * @param array $data 52 * @param string $dataName 53 */ 54 final public function __construct($name = null, array $data = array(), $dataName = '') { 55 parent::__construct($name, $data, $dataName); 56 57 $this->setBackupGlobals(false); 58 $this->setBackupStaticAttributes(false); 59 $this->setPreserveGlobalState(false); 60 } 61 62 /** 63 * Runs the bare test sequence. 64 * @return void 65 */ 66 final public function runBare(): void { 67 global $DB; 68 69 if (phpunit_util::$lastdbwrites != $DB->perf_get_writes()) { 70 // this happens when previous test does not reset, we can not use transactions 71 $this->testdbtransaction = null; 72 73 } else if ($DB->get_dbfamily() === 'postgres' or $DB->get_dbfamily() === 'mssql') { 74 // database must allow rollback of DDL, so no mysql here 75 $this->testdbtransaction = $DB->start_delegated_transaction(); 76 } 77 78 try { 79 $this->setCurrentTimeStart(); 80 parent::runBare(); 81 // set DB reference in case somebody mocked it in test 82 $DB = phpunit_util::get_global_backup('DB'); 83 84 // Deal with any debugging messages. 85 $debugerror = phpunit_util::display_debugging_messages(true); 86 $this->resetDebugging(); 87 if (!empty($debugerror)) { 88 trigger_error('Unexpected debugging() call detected.'."\n".$debugerror, E_USER_NOTICE); 89 } 90 91 } catch (Exception $ex) { 92 $e = $ex; 93 } catch (Throwable $ex) { 94 // Engine errors in PHP7 throw exceptions of type Throwable (this "catch" will be ignored in PHP5). 95 $e = $ex; 96 } 97 98 if (isset($e)) { 99 // cleanup after failed expectation 100 self::resetAllData(); 101 throw $e; 102 } 103 104 if (!$this->testdbtransaction or $this->testdbtransaction->is_disposed()) { 105 $this->testdbtransaction = null; 106 } 107 108 if ($this->resetAfterTest === true) { 109 if ($this->testdbtransaction) { 110 $DB->force_transaction_rollback(); 111 phpunit_util::reset_all_database_sequences(); 112 phpunit_util::$lastdbwrites = $DB->perf_get_writes(); // no db reset necessary 113 } 114 self::resetAllData(null); 115 116 } else if ($this->resetAfterTest === false) { 117 if ($this->testdbtransaction) { 118 $this->testdbtransaction->allow_commit(); 119 } 120 // keep all data untouched for other tests 121 122 } else { 123 // reset but log what changed 124 if ($this->testdbtransaction) { 125 try { 126 $this->testdbtransaction->allow_commit(); 127 } catch (dml_transaction_exception $e) { 128 self::resetAllData(); 129 throw new coding_exception('Invalid transaction state detected in test '.$this->getName()); 130 } 131 } 132 self::resetAllData(true); 133 } 134 135 // Reset context cache. 136 context_helper::reset_caches(); 137 138 // make sure test did not forget to close transaction 139 if ($DB->is_transaction_started()) { 140 self::resetAllData(); 141 if ($this->getStatus() == PHPUnit\Runner\BaseTestRunner::STATUS_PASSED 142 or $this->getStatus() == PHPUnit\Runner\BaseTestRunner::STATUS_SKIPPED 143 or $this->getStatus() == PHPUnit\Runner\BaseTestRunner::STATUS_INCOMPLETE) { 144 throw new coding_exception('Test '.$this->getName().' did not close database transaction'); 145 } 146 } 147 } 148 149 /** 150 * Creates a new FlatXmlDataSet with the given $xmlFile. (absolute path.) 151 * 152 * @param string $xmlFile 153 * @return PHPUnit\DbUnit\DataSet\FlatXmlDataSet 154 */ 155 protected function createFlatXMLDataSet($xmlFile) { 156 return new PHPUnit\DbUnit\DataSet\FlatXmlDataSet($xmlFile); 157 } 158 159 /** 160 * Creates a new XMLDataSet with the given $xmlFile. (absolute path.) 161 * 162 * @param string $xmlFile 163 * @return PHPUnit\DbUnit\DataSet\XmlDataSet 164 */ 165 protected function createXMLDataSet($xmlFile) { 166 return new PHPUnit\DbUnit\DataSet\XmlDataSet($xmlFile); 167 } 168 169 /** 170 * Creates a new CsvDataSet from the given array of csv files. (absolute paths.) 171 * 172 * @param array $files array tablename=>cvsfile 173 * @param string $delimiter 174 * @param string $enclosure 175 * @param string $escape 176 * @return PHPUnit\DbUnit\DataSet\CsvDataSet 177 */ 178 protected function createCsvDataSet($files, $delimiter = ',', $enclosure = '"', $escape = '"') { 179 $dataSet = new PHPUnit\DbUnit\DataSet\CsvDataSet($delimiter, $enclosure, $escape); 180 foreach($files as $table=>$file) { 181 $dataSet->addTable($table, $file); 182 } 183 return $dataSet; 184 } 185 186 /** 187 * Creates new ArrayDataSet from given array 188 * 189 * @param array $data array of tables, first row in each table is columns 190 * @return phpunit_ArrayDataSet 191 */ 192 protected function createArrayDataSet(array $data) { 193 return new phpunit_ArrayDataSet($data); 194 } 195 196 /** 197 * Load date into moodle database tables from standard PHPUnit data set. 198 * 199 * Note: it is usually better to use data generators 200 * 201 * @param PHPUnit\DbUnit\DataSet\IDataSet $dataset 202 * @return void 203 */ 204 protected function loadDataSet(PHPUnit\DbUnit\DataSet\IDataSet $dataset) { 205 global $DB; 206 207 $structure = phpunit_util::get_tablestructure(); 208 209 foreach($dataset->getTableNames() as $tablename) { 210 $table = $dataset->getTable($tablename); 211 $metadata = $dataset->getTableMetaData($tablename); 212 $columns = $metadata->getColumns(); 213 214 $doimport = false; 215 if (isset($structure[$tablename]['id']) and $structure[$tablename]['id']->auto_increment) { 216 $doimport = in_array('id', $columns); 217 } 218 219 for($r=0; $r<$table->getRowCount(); $r++) { 220 $record = $table->getRow($r); 221 if ($doimport) { 222 $DB->import_record($tablename, $record); 223 } else { 224 $DB->insert_record($tablename, $record); 225 } 226 } 227 if ($doimport) { 228 $DB->get_manager()->reset_sequence(new xmldb_table($tablename)); 229 } 230 } 231 } 232 233 /** 234 * Call this method from test if you want to make sure that 235 * the resetting of database is done the slow way without transaction 236 * rollback. 237 * 238 * This is useful especially when testing stuff that is not compatible with transactions. 239 * 240 * @return void 241 */ 242 public function preventResetByRollback() { 243 if ($this->testdbtransaction and !$this->testdbtransaction->is_disposed()) { 244 $this->testdbtransaction->allow_commit(); 245 $this->testdbtransaction = null; 246 } 247 } 248 249 /** 250 * Reset everything after current test. 251 * @param bool $reset true means reset state back, false means keep all data for the next test, 252 * null means reset state and show warnings if anything changed 253 * @return void 254 */ 255 public function resetAfterTest($reset = true) { 256 $this->resetAfterTest = $reset; 257 } 258 259 /** 260 * Return debugging messages from the current test. 261 * @return array with instances having 'message', 'level' and 'stacktrace' property. 262 */ 263 public function getDebuggingMessages() { 264 return phpunit_util::get_debugging_messages(); 265 } 266 267 /** 268 * Clear all previous debugging messages in current test 269 * and revert to default DEVELOPER_DEBUG level. 270 */ 271 public function resetDebugging() { 272 phpunit_util::reset_debugging(); 273 } 274 275 /** 276 * Assert that exactly debugging was just called once. 277 * 278 * Discards the debugging message if successful. 279 * 280 * @param null|string $debugmessage null means any 281 * @param null|string $debuglevel null means any 282 * @param string $message 283 */ 284 public function assertDebuggingCalled($debugmessage = null, $debuglevel = null, $message = '') { 285 $debugging = $this->getDebuggingMessages(); 286 $debugdisplaymessage = "\n".phpunit_util::display_debugging_messages(true); 287 $this->resetDebugging(); 288 289 $count = count($debugging); 290 291 if ($count == 0) { 292 if ($message === '') { 293 $message = 'Expectation failed, debugging() not triggered.'; 294 } 295 $this->fail($message); 296 } 297 if ($count > 1) { 298 if ($message === '') { 299 $message = 'Expectation failed, debugging() triggered '.$count.' times.'.$debugdisplaymessage; 300 } 301 $this->fail($message); 302 } 303 $this->assertEquals(1, $count); 304 305 $message .= $debugdisplaymessage; 306 $debug = reset($debugging); 307 if ($debugmessage !== null) { 308 $this->assertSame($debugmessage, $debug->message, $message); 309 } 310 if ($debuglevel !== null) { 311 $this->assertSame($debuglevel, $debug->level, $message); 312 } 313 } 314 315 /** 316 * Asserts how many times debugging has been called. 317 * 318 * @param int $expectedcount The expected number of times 319 * @param array $debugmessages Expected debugging messages, one for each expected message. 320 * @param array $debuglevels Expected debugging levels, one for each expected message. 321 * @param string $message 322 * @return void 323 */ 324 public function assertDebuggingCalledCount($expectedcount, $debugmessages = array(), $debuglevels = array(), $message = '') { 325 if (!is_int($expectedcount)) { 326 throw new coding_exception('assertDebuggingCalledCount $expectedcount argument should be an integer.'); 327 } 328 329 $debugging = $this->getDebuggingMessages(); 330 $message .= "\n".phpunit_util::display_debugging_messages(true); 331 $this->resetDebugging(); 332 333 $this->assertEquals($expectedcount, count($debugging), $message); 334 335 if ($debugmessages) { 336 if (!is_array($debugmessages) || count($debugmessages) != $expectedcount) { 337 throw new coding_exception('assertDebuggingCalledCount $debugmessages should contain ' . $expectedcount . ' messages'); 338 } 339 foreach ($debugmessages as $key => $debugmessage) { 340 $this->assertSame($debugmessage, $debugging[$key]->message, $message); 341 } 342 } 343 344 if ($debuglevels) { 345 if (!is_array($debuglevels) || count($debuglevels) != $expectedcount) { 346 throw new coding_exception('assertDebuggingCalledCount $debuglevels should contain ' . $expectedcount . ' messages'); 347 } 348 foreach ($debuglevels as $key => $debuglevel) { 349 $this->assertSame($debuglevel, $debugging[$key]->level, $message); 350 } 351 } 352 } 353 354 /** 355 * Call when no debugging() messages expected. 356 * @param string $message 357 */ 358 public function assertDebuggingNotCalled($message = '') { 359 $debugging = $this->getDebuggingMessages(); 360 $count = count($debugging); 361 362 if ($message === '') { 363 $message = 'Expectation failed, debugging() was triggered.'; 364 } 365 $message .= "\n".phpunit_util::display_debugging_messages(true); 366 $this->resetDebugging(); 367 $this->assertEquals(0, $count, $message); 368 } 369 370 /** 371 * Assert that an event legacy data is equal to the expected value. 372 * 373 * @param mixed $expected expected data. 374 * @param \core\event\base $event the event object. 375 * @param string $message 376 * @return void 377 */ 378 public function assertEventLegacyData($expected, \core\event\base $event, $message = '') { 379 $legacydata = phpunit_event_mock::testable_get_legacy_eventdata($event); 380 if ($message === '') { 381 $message = 'Event legacy data does not match expected value.'; 382 } 383 $this->assertEquals($expected, $legacydata, $message); 384 } 385 386 /** 387 * Assert that an event legacy log data is equal to the expected value. 388 * 389 * @param mixed $expected expected data. 390 * @param \core\event\base $event the event object. 391 * @param string $message 392 * @return void 393 */ 394 public function assertEventLegacyLogData($expected, \core\event\base $event, $message = '') { 395 $legacydata = phpunit_event_mock::testable_get_legacy_logdata($event); 396 if ($message === '') { 397 $message = 'Event legacy log data does not match expected value.'; 398 } 399 $this->assertEquals($expected, $legacydata, $message); 400 } 401 402 /** 403 * Assert that an event is not using event->contxet. 404 * While restoring context might not be valid and it should not be used by event url 405 * or description methods. 406 * 407 * @param \core\event\base $event the event object. 408 * @param string $message 409 * @return void 410 */ 411 public function assertEventContextNotUsed(\core\event\base $event, $message = '') { 412 // Save current event->context and set it to false. 413 $eventcontext = phpunit_event_mock::testable_get_event_context($event); 414 phpunit_event_mock::testable_set_event_context($event, false); 415 if ($message === '') { 416 $message = 'Event should not use context property of event in any method.'; 417 } 418 419 // Test event methods should not use event->context. 420 $event->get_url(); 421 $event->get_description(); 422 $event->get_legacy_eventname(); 423 phpunit_event_mock::testable_get_legacy_eventdata($event); 424 phpunit_event_mock::testable_get_legacy_logdata($event); 425 426 // Restore event->context. 427 phpunit_event_mock::testable_set_event_context($event, $eventcontext); 428 } 429 430 /** 431 * Stores current time as the base for assertTimeCurrent(). 432 * 433 * Note: this is called automatically before calling individual test methods. 434 * @return int current time 435 */ 436 public function setCurrentTimeStart() { 437 $this->currenttimestart = time(); 438 return $this->currenttimestart; 439 } 440 441 /** 442 * Assert that: start < $time < time() 443 * @param int $time 444 * @param string $message 445 * @return void 446 */ 447 public function assertTimeCurrent($time, $message = '') { 448 $msg = ($message === '') ? 'Time is lower that allowed start value' : $message; 449 $this->assertGreaterThanOrEqual($this->currenttimestart, $time, $msg); 450 $msg = ($message === '') ? 'Time is in the future' : $message; 451 $this->assertLessThanOrEqual(time(), $time, $msg); 452 } 453 454 /** 455 * Starts message redirection. 456 * 457 * You can verify if messages were sent or not by inspecting the messages 458 * array in the returned messaging sink instance. The redirection 459 * can be stopped by calling $sink->close(); 460 * 461 * @return phpunit_message_sink 462 */ 463 public function redirectMessages() { 464 return phpunit_util::start_message_redirection(); 465 } 466 467 /** 468 * Starts email redirection. 469 * 470 * You can verify if email were sent or not by inspecting the email 471 * array in the returned phpmailer sink instance. The redirection 472 * can be stopped by calling $sink->close(); 473 * 474 * @return phpunit_message_sink 475 */ 476 public function redirectEmails() { 477 return phpunit_util::start_phpmailer_redirection(); 478 } 479 480 /** 481 * Starts event redirection. 482 * 483 * You can verify if events were triggered or not by inspecting the events 484 * array in the returned event sink instance. The redirection 485 * can be stopped by calling $sink->close(); 486 * 487 * @return phpunit_event_sink 488 */ 489 public function redirectEvents() { 490 return phpunit_util::start_event_redirection(); 491 } 492 493 /** 494 * Reset all database tables, restore global state and clear caches and optionally purge dataroot dir. 495 * 496 * @param bool $detectchanges 497 * true - changes in global state and database are reported as errors 498 * false - no errors reported 499 * null - only critical problems are reported as errors 500 * @return void 501 */ 502 public static function resetAllData($detectchanges = false) { 503 phpunit_util::reset_all_data($detectchanges); 504 } 505 506 /** 507 * Set current $USER, reset access cache. 508 * @static 509 * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid 510 * @return void 511 */ 512 public static function setUser($user = null) { 513 global $CFG, $DB; 514 515 if (is_object($user)) { 516 $user = clone($user); 517 } else if (!$user) { 518 $user = new stdClass(); 519 $user->id = 0; 520 $user->mnethostid = $CFG->mnet_localhost_id; 521 } else { 522 $user = $DB->get_record('user', array('id'=>$user)); 523 } 524 unset($user->description); 525 unset($user->access); 526 unset($user->preference); 527 528 // Enusre session is empty, as it may contain caches and user specific info. 529 \core\session\manager::init_empty_session(); 530 531 \core\session\manager::set_user($user); 532 } 533 534 /** 535 * Set current $USER to admin account, reset access cache. 536 * @static 537 * @return void 538 */ 539 public static function setAdminUser() { 540 self::setUser(2); 541 } 542 543 /** 544 * Set current $USER to guest account, reset access cache. 545 * @static 546 * @return void 547 */ 548 public static function setGuestUser() { 549 self::setUser(1); 550 } 551 552 /** 553 * Change server and default php timezones. 554 * 555 * @param string $servertimezone timezone to set in $CFG->timezone (not validated) 556 * @param string $defaultphptimezone timezone to fake default php timezone (must be valid) 557 */ 558 public static function setTimezone($servertimezone = 'Australia/Perth', $defaultphptimezone = 'Australia/Perth') { 559 global $CFG; 560 $CFG->timezone = $servertimezone; 561 core_date::phpunit_override_default_php_timezone($defaultphptimezone); 562 core_date::set_default_server_timezone(); 563 } 564 565 /** 566 * Get data generator 567 * @static 568 * @return testing_data_generator 569 */ 570 public static function getDataGenerator() { 571 return phpunit_util::get_data_generator(); 572 } 573 574 /** 575 * Returns UTL of the external test file. 576 * 577 * The result depends on the value of following constants: 578 * - TEST_EXTERNAL_FILES_HTTP_URL 579 * - TEST_EXTERNAL_FILES_HTTPS_URL 580 * 581 * They should point to standard external test files repository, 582 * it defaults to 'http://download.moodle.org/unittest'. 583 * 584 * False value means skip tests that require external files. 585 * 586 * @param string $path 587 * @param bool $https true if https required 588 * @return string url 589 */ 590 public function getExternalTestFileUrl($path, $https = false) { 591 $path = ltrim($path, '/'); 592 if ($path) { 593 $path = '/'.$path; 594 } 595 if ($https) { 596 if (defined('TEST_EXTERNAL_FILES_HTTPS_URL')) { 597 if (!TEST_EXTERNAL_FILES_HTTPS_URL) { 598 $this->markTestSkipped('Tests using external https test files are disabled'); 599 } 600 return TEST_EXTERNAL_FILES_HTTPS_URL.$path; 601 } 602 return 'https://download.moodle.org/unittest'.$path; 603 } 604 605 if (defined('TEST_EXTERNAL_FILES_HTTP_URL')) { 606 if (!TEST_EXTERNAL_FILES_HTTP_URL) { 607 $this->markTestSkipped('Tests using external http test files are disabled'); 608 } 609 return TEST_EXTERNAL_FILES_HTTP_URL.$path; 610 } 611 return 'http://download.moodle.org/unittest'.$path; 612 } 613 614 /** 615 * Recursively visit all the files in the source tree. Calls the callback 616 * function with the pathname of each file found. 617 * 618 * @param string $path the folder to start searching from. 619 * @param string $callback the method of this class to call with the name of each file found. 620 * @param string $fileregexp a regexp used to filter the search (optional). 621 * @param bool $exclude If true, pathnames that match the regexp will be ignored. If false, 622 * only files that match the regexp will be included. (default false). 623 * @param array $ignorefolders will not go into any of these folders (optional). 624 * @return void 625 */ 626 public function recurseFolders($path, $callback, $fileregexp = '/.*/', $exclude = false, $ignorefolders = array()) { 627 $files = scandir($path); 628 629 foreach ($files as $file) { 630 $filepath = $path .'/'. $file; 631 if (strpos($file, '.') === 0) { 632 /// Don't check hidden files. 633 continue; 634 } else if (is_dir($filepath)) { 635 if (!in_array($filepath, $ignorefolders)) { 636 $this->recurseFolders($filepath, $callback, $fileregexp, $exclude, $ignorefolders); 637 } 638 } else if ($exclude xor preg_match($fileregexp, $filepath)) { 639 $this->$callback($filepath); 640 } 641 } 642 } 643 644 /** 645 * Wait for a second to roll over, ensures future calls to time() return a different result. 646 * 647 * This is implemented instead of sleep() as we do not need to wait a full second. In some cases 648 * due to calls we may wait more than sleep() would have, on average it will be less. 649 */ 650 public function waitForSecond() { 651 $starttime = time(); 652 while (time() == $starttime) { 653 usleep(50000); 654 } 655 } 656 657 /** 658 * Run adhoc tasks, optionally matching the specified classname. 659 * 660 * @param string $matchclass The name of the class to match on. 661 * @param int $matchuserid The userid to match. 662 */ 663 protected function runAdhocTasks($matchclass = '', $matchuserid = null) { 664 global $CFG, $DB; 665 require_once($CFG->libdir.'/cronlib.php'); 666 667 $params = []; 668 if (!empty($matchclass)) { 669 if (strpos($matchclass, '\\') !== 0) { 670 $matchclass = '\\' . $matchclass; 671 } 672 $params['classname'] = $matchclass; 673 } 674 675 if (!empty($matchuserid)) { 676 $params['userid'] = $matchuserid; 677 } 678 679 $lock = $this->createMock(\core\lock\lock::class); 680 $cronlock = $this->createMock(\core\lock\lock::class); 681 682 $tasks = $DB->get_recordset('task_adhoc', $params); 683 foreach ($tasks as $record) { 684 // Note: This is for cron only. 685 // We do not lock the tasks. 686 $task = \core\task\manager::adhoc_task_from_record($record); 687 688 $user = null; 689 if ($userid = $task->get_userid()) { 690 // This task has a userid specified. 691 $user = \core_user::get_user($userid); 692 693 // User found. Check that they are suitable. 694 \core_user::require_active_user($user, true, true); 695 } 696 697 $task->set_lock($lock); 698 if (!$task->is_blocking()) { 699 $cronlock->release(); 700 } else { 701 $task->set_cron_lock($cronlock); 702 } 703 704 cron_prepare_core_renderer(); 705 $this->setUser($user); 706 707 $task->execute(); 708 \core\task\manager::adhoc_task_complete($task); 709 710 unset($task); 711 } 712 $tasks->close(); 713 } 714} 715