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