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 * Testing util classes
19 *
20 * @abstract
21 * @package    core
22 * @category   test
23 * @copyright  2012 Petr Skoda {@link http://skodak.org}
24 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
27/**
28 * Utils for test sites creation
29 *
30 * @package   core
31 * @category  test
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 testing_util {
36
37    /**
38     * @var string dataroot (likely to be $CFG->dataroot).
39     */
40    private static $dataroot = null;
41
42    /**
43     * @var testing_data_generator
44     */
45    protected static $generator = null;
46
47    /**
48     * @var string current version hash from php files
49     */
50    protected static $versionhash = null;
51
52    /**
53     * @var array original content of all database tables
54     */
55    protected static $tabledata = null;
56
57    /**
58     * @var array original structure of all database tables
59     */
60    protected static $tablestructure = null;
61
62    /**
63     * @var array keep list of sequenceid used in a table.
64     */
65    private static $tablesequences = array();
66
67    /**
68     * @var array list of updated tables.
69     */
70    public static $tableupdated = array();
71
72    /**
73     * @var array original structure of all database tables
74     */
75    protected static $sequencenames = null;
76
77    /**
78     * @var string name of the json file where we store the list of dataroot files to not reset during reset_dataroot.
79     */
80    private static $originaldatafilesjson = 'originaldatafiles.json';
81
82    /**
83     * @var boolean set to true once $originaldatafilesjson file is created.
84     */
85    private static $originaldatafilesjsonadded = false;
86
87    /**
88     * @var int next sequence value for a single test cycle.
89     */
90    protected static $sequencenextstartingid = null;
91
92    /**
93     * Return the name of the JSON file containing the init filenames.
94     *
95     * @static
96     * @return string
97     */
98    public static function get_originaldatafilesjson() {
99        return self::$originaldatafilesjson;
100    }
101
102    /**
103     * Return the dataroot. It's useful when mocking the dataroot when unit testing this class itself.
104     *
105     * @static
106     * @return string the dataroot.
107     */
108    public static function get_dataroot() {
109        global $CFG;
110
111        //  By default it's the test framework dataroot.
112        if (empty(self::$dataroot)) {
113            self::$dataroot = $CFG->dataroot;
114        }
115
116        return self::$dataroot;
117    }
118
119    /**
120     * Set the dataroot. It's useful when mocking the dataroot when unit testing this class itself.
121     *
122     * @param string $dataroot the dataroot of the test framework.
123     * @static
124     */
125    public static function set_dataroot($dataroot) {
126        self::$dataroot = $dataroot;
127    }
128
129    /**
130     * Returns the testing framework name
131     * @static
132     * @return string
133     */
134    protected static final function get_framework() {
135        $classname = get_called_class();
136        return substr($classname, 0, strpos($classname, '_'));
137    }
138
139    /**
140     * Get data generator
141     * @static
142     * @return testing_data_generator
143     */
144    public static function get_data_generator() {
145        if (is_null(self::$generator)) {
146            require_once(__DIR__.'/../generator/lib.php');
147            self::$generator = new testing_data_generator();
148        }
149        return self::$generator;
150    }
151
152    /**
153     * Does this site (db and dataroot) appear to be used for production?
154     * We try very hard to prevent accidental damage done to production servers!!
155     *
156     * @static
157     * @return bool
158     */
159    public static function is_test_site() {
160        global $DB, $CFG;
161
162        $framework = self::get_framework();
163
164        if (!file_exists(self::get_dataroot() . '/' . $framework . 'testdir.txt')) {
165            // this is already tested in bootstrap script,
166            // but anyway presence of this file means the dataroot is for testing
167            return false;
168        }
169
170        $tables = $DB->get_tables(false);
171        if ($tables) {
172            if (!$DB->get_manager()->table_exists('config')) {
173                return false;
174            }
175            if (!get_config('core', $framework . 'test')) {
176                return false;
177            }
178        }
179
180        return true;
181    }
182
183    /**
184     * Returns whether test database and dataroot were created using the current version codebase
185     *
186     * @return bool
187     */
188    public static function is_test_data_updated() {
189        global $DB;
190
191        $framework = self::get_framework();
192
193        $datarootpath = self::get_dataroot() . '/' . $framework;
194        if (!file_exists($datarootpath . '/tabledata.ser') or !file_exists($datarootpath . '/tablestructure.ser')) {
195            return false;
196        }
197
198        if (!file_exists($datarootpath . '/versionshash.txt')) {
199            return false;
200        }
201
202        $hash = core_component::get_all_versions_hash();
203        $oldhash = file_get_contents($datarootpath . '/versionshash.txt');
204
205        if ($hash !== $oldhash) {
206            return false;
207        }
208
209        // A direct database request must be used to avoid any possible caching of an older value.
210        $dbhash = $DB->get_field('config', 'value', array('name' => $framework . 'test'));
211        if ($hash !== $dbhash) {
212            return false;
213        }
214
215        return true;
216    }
217
218    /**
219     * Stores the status of the database
220     *
221     * Serializes the contents and the structure and
222     * stores it in the test framework space in dataroot
223     */
224    protected static function store_database_state() {
225        global $DB, $CFG;
226
227        $framework = self::get_framework();
228
229        // store data for all tables
230        $data = array();
231        $structure = array();
232        $tables = $DB->get_tables();
233        foreach ($tables as $table) {
234            $columns = $DB->get_columns($table);
235            $structure[$table] = $columns;
236            if (isset($columns['id']) and $columns['id']->auto_increment) {
237                $data[$table] = $DB->get_records($table, array(), 'id ASC');
238            } else {
239                // there should not be many of these
240                $data[$table] = $DB->get_records($table, array());
241            }
242        }
243        $data = serialize($data);
244        $datafile = self::get_dataroot() . '/' . $framework . '/tabledata.ser';
245        file_put_contents($datafile, $data);
246        testing_fix_file_permissions($datafile);
247
248        $structure = serialize($structure);
249        $structurefile = self::get_dataroot() . '/' . $framework . '/tablestructure.ser';
250        file_put_contents($structurefile, $structure);
251        testing_fix_file_permissions($structurefile);
252    }
253
254    /**
255     * Stores the version hash in both database and dataroot
256     */
257    protected static function store_versions_hash() {
258        global $CFG;
259
260        $framework = self::get_framework();
261        $hash = core_component::get_all_versions_hash();
262
263        // add test db flag
264        set_config($framework . 'test', $hash);
265
266        // hash all plugin versions - helps with very fast detection of db structure changes
267        $hashfile = self::get_dataroot() . '/' . $framework . '/versionshash.txt';
268        file_put_contents($hashfile, $hash);
269        testing_fix_file_permissions($hashfile);
270    }
271
272    /**
273     * Returns contents of all tables right after installation.
274     * @static
275     * @return array  $table=>$records
276     */
277    protected static function get_tabledata() {
278        if (!isset(self::$tabledata)) {
279            $framework = self::get_framework();
280
281            $datafile = self::get_dataroot() . '/' . $framework . '/tabledata.ser';
282            if (!file_exists($datafile)) {
283                // Not initialised yet.
284                return array();
285            }
286
287            $data = file_get_contents($datafile);
288            self::$tabledata = unserialize($data);
289        }
290
291        if (!is_array(self::$tabledata)) {
292            testing_error(1, 'Can not read dataroot/' . $framework . '/tabledata.ser or invalid format, reinitialize test database.');
293        }
294
295        return self::$tabledata;
296    }
297
298    /**
299     * Returns structure of all tables right after installation.
300     * @static
301     * @return array $table=>$records
302     */
303    public static function get_tablestructure() {
304        if (!isset(self::$tablestructure)) {
305            $framework = self::get_framework();
306
307            $structurefile = self::get_dataroot() . '/' . $framework . '/tablestructure.ser';
308            if (!file_exists($structurefile)) {
309                // Not initialised yet.
310                return array();
311            }
312
313            $data = file_get_contents($structurefile);
314            self::$tablestructure = unserialize($data);
315        }
316
317        if (!is_array(self::$tablestructure)) {
318            testing_error(1, 'Can not read dataroot/' . $framework . '/tablestructure.ser or invalid format, reinitialize test database.');
319        }
320
321        return self::$tablestructure;
322    }
323
324    /**
325     * Returns the names of sequences for each autoincrementing id field in all standard tables.
326     * @static
327     * @return array $table=>$sequencename
328     */
329    public static function get_sequencenames() {
330        global $DB;
331
332        if (isset(self::$sequencenames)) {
333            return self::$sequencenames;
334        }
335
336        if (!$structure = self::get_tablestructure()) {
337            return array();
338        }
339
340        self::$sequencenames = array();
341        foreach ($structure as $table => $ignored) {
342            $name = $DB->get_manager()->generator->getSequenceFromDB(new xmldb_table($table));
343            if ($name !== false) {
344                self::$sequencenames[$table] = $name;
345            }
346        }
347
348        return self::$sequencenames;
349    }
350
351    /**
352     * Returns list of tables that are unmodified and empty.
353     *
354     * @static
355     * @return array of table names, empty if unknown
356     */
357    protected static function guess_unmodified_empty_tables() {
358        global $DB;
359
360        $dbfamily = $DB->get_dbfamily();
361
362        if ($dbfamily === 'mysql') {
363            $empties = array();
364            $prefix = $DB->get_prefix();
365            $rs = $DB->get_recordset_sql("SHOW TABLE STATUS LIKE ?", array($prefix.'%'));
366            foreach ($rs as $info) {
367                $table = strtolower($info->name);
368                if (strpos($table, $prefix) !== 0) {
369                    // incorrect table match caused by _
370                    continue;
371                }
372
373                if (!is_null($info->auto_increment) && $info->rows == 0 && ($info->auto_increment == 1)) {
374                    $table = preg_replace('/^'.preg_quote($prefix, '/').'/', '', $table);
375                    $empties[$table] = $table;
376                }
377            }
378            $rs->close();
379            return $empties;
380
381        } else if ($dbfamily === 'mssql') {
382            $empties = array();
383            $prefix = $DB->get_prefix();
384            $sql = "SELECT t.name
385                      FROM sys.identity_columns i
386                      JOIN sys.tables t ON t.object_id = i.object_id
387                     WHERE t.name LIKE ?
388                       AND i.name = 'id'
389                       AND i.last_value IS NULL";
390            $rs = $DB->get_recordset_sql($sql, array($prefix.'%'));
391            foreach ($rs as $info) {
392                $table = strtolower($info->name);
393                if (strpos($table, $prefix) !== 0) {
394                    // incorrect table match caused by _
395                    continue;
396                }
397                $table = preg_replace('/^'.preg_quote($prefix, '/').'/', '', $table);
398                $empties[$table] = $table;
399            }
400            $rs->close();
401            return $empties;
402
403        } else if ($dbfamily === 'oracle') {
404            $sequences = self::get_sequencenames();
405            $sequences = array_map('strtoupper', $sequences);
406            $lookup = array_flip($sequences);
407            $empties = array();
408            list($seqs, $params) = $DB->get_in_or_equal($sequences);
409            $sql = "SELECT sequence_name FROM user_sequences WHERE last_number = 1 AND sequence_name $seqs";
410            $rs = $DB->get_recordset_sql($sql, $params);
411            foreach ($rs as $seq) {
412                $table = $lookup[$seq->sequence_name];
413                $empties[$table] = $table;
414            }
415            $rs->close();
416            return $empties;
417
418        } else {
419            return array();
420        }
421    }
422
423    /**
424     * Determine the next unique starting id sequences.
425     *
426     * @static
427     * @param array $records The records to use to determine the starting value for the table.
428     * @param string $table table name.
429     * @return int The value the sequence should be set to.
430     */
431    private static function get_next_sequence_starting_value($records, $table) {
432        if (isset(self::$tablesequences[$table])) {
433            return self::$tablesequences[$table];
434        }
435
436        $id = self::$sequencenextstartingid;
437
438        // If there are records, calculate the minimum id we can use.
439        // It must be bigger than the last record's id.
440        if (!empty($records)) {
441            $lastrecord = end($records);
442            $id = max($id, $lastrecord->id + 1);
443        }
444
445        self::$sequencenextstartingid = $id + 1000;
446
447        self::$tablesequences[$table] = $id;
448
449        return $id;
450    }
451
452    /**
453     * Reset all database sequences to initial values.
454     *
455     * @static
456     * @param array $empties tables that are known to be unmodified and empty
457     * @return void
458     */
459    public static function reset_all_database_sequences(array $empties = null) {
460        global $DB;
461
462        if (!$data = self::get_tabledata()) {
463            // Not initialised yet.
464            return;
465        }
466        if (!$structure = self::get_tablestructure()) {
467            // Not initialised yet.
468            return;
469        }
470
471        $updatedtables = self::$tableupdated;
472
473        // If all starting Id's are the same, it's difficult to detect coding and testing
474        // errors that use the incorrect id in tests.  The classic case is cmid vs instance id.
475        // To reduce the chance of the coding error, we start sequences at different values where possible.
476        // In a attempt to avoid tables with existing id's we start at a high number.
477        // Reset the value each time all database sequences are reset.
478        if (defined('PHPUNIT_SEQUENCE_START') and PHPUNIT_SEQUENCE_START) {
479            self::$sequencenextstartingid = PHPUNIT_SEQUENCE_START;
480        } else {
481            self::$sequencenextstartingid = 100000;
482        }
483
484        $dbfamily = $DB->get_dbfamily();
485        if ($dbfamily === 'postgres') {
486            $queries = array();
487            $prefix = $DB->get_prefix();
488            foreach ($data as $table => $records) {
489                // If table is not modified then no need to do anything.
490                if (!isset($updatedtables[$table])) {
491                    continue;
492                }
493                if (isset($structure[$table]['id']) and $structure[$table]['id']->auto_increment) {
494                    $nextid = self::get_next_sequence_starting_value($records, $table);
495                    $queries[] = "ALTER SEQUENCE {$prefix}{$table}_id_seq RESTART WITH $nextid";
496                }
497            }
498            if ($queries) {
499                $DB->change_database_structure(implode(';', $queries));
500            }
501
502        } else if ($dbfamily === 'mysql') {
503            $queries = array();
504            $sequences = array();
505            $prefix = $DB->get_prefix();
506            $rs = $DB->get_recordset_sql("SHOW TABLE STATUS LIKE ?", array($prefix.'%'));
507            foreach ($rs as $info) {
508                $table = strtolower($info->name);
509                if (strpos($table, $prefix) !== 0) {
510                    // incorrect table match caused by _
511                    continue;
512                }
513                if (!is_null($info->auto_increment)) {
514                    $table = preg_replace('/^'.preg_quote($prefix, '/').'/', '', $table);
515                    $sequences[$table] = $info->auto_increment;
516                }
517            }
518            $rs->close();
519            $prefix = $DB->get_prefix();
520            foreach ($data as $table => $records) {
521                // If table is not modified then no need to do anything.
522                if (!isset($updatedtables[$table])) {
523                    continue;
524                }
525                if (isset($structure[$table]['id']) and $structure[$table]['id']->auto_increment) {
526                    if (isset($sequences[$table])) {
527                        $nextid = self::get_next_sequence_starting_value($records, $table);
528                        if ($sequences[$table] != $nextid) {
529                            $queries[] = "ALTER TABLE {$prefix}{$table} AUTO_INCREMENT = $nextid";
530                        }
531                    } else {
532                        // some problem exists, fallback to standard code
533                        $DB->get_manager()->reset_sequence($table);
534                    }
535                }
536            }
537            if ($queries) {
538                $DB->change_database_structure(implode(';', $queries));
539            }
540
541        } else if ($dbfamily === 'oracle') {
542            $sequences = self::get_sequencenames();
543            $sequences = array_map('strtoupper', $sequences);
544            $lookup = array_flip($sequences);
545
546            $current = array();
547            list($seqs, $params) = $DB->get_in_or_equal($sequences);
548            $sql = "SELECT sequence_name, last_number FROM user_sequences WHERE sequence_name $seqs";
549            $rs = $DB->get_recordset_sql($sql, $params);
550            foreach ($rs as $seq) {
551                $table = $lookup[$seq->sequence_name];
552                $current[$table] = $seq->last_number;
553            }
554            $rs->close();
555
556            foreach ($data as $table => $records) {
557                // If table is not modified then no need to do anything.
558                if (!isset($updatedtables[$table])) {
559                    continue;
560                }
561                if (isset($structure[$table]['id']) and $structure[$table]['id']->auto_increment) {
562                    $lastrecord = end($records);
563                    if ($lastrecord) {
564                        $nextid = $lastrecord->id + 1;
565                    } else {
566                        $nextid = 1;
567                    }
568                    if (!isset($current[$table])) {
569                        $DB->get_manager()->reset_sequence($table);
570                    } else if ($nextid == $current[$table]) {
571                        continue;
572                    }
573                    // reset as fast as possible - alternatively we could use http://stackoverflow.com/questions/51470/how-do-i-reset-a-sequence-in-oracle
574                    $seqname = $sequences[$table];
575                    $cachesize = $DB->get_manager()->generator->sequence_cache_size;
576                    $DB->change_database_structure("DROP SEQUENCE $seqname");
577                    $DB->change_database_structure("CREATE SEQUENCE $seqname START WITH $nextid INCREMENT BY 1 NOMAXVALUE CACHE $cachesize");
578                }
579            }
580
581        } else {
582            // note: does mssql support any kind of faster reset?
583            // This also implies mssql will not use unique sequence values.
584            if (is_null($empties) and (empty($updatedtables))) {
585                $empties = self::guess_unmodified_empty_tables();
586            }
587            foreach ($data as $table => $records) {
588                // If table is not modified then no need to do anything.
589                if (isset($empties[$table]) or (!isset($updatedtables[$table]))) {
590                    continue;
591                }
592                if (isset($structure[$table]['id']) and $structure[$table]['id']->auto_increment) {
593                    $DB->get_manager()->reset_sequence($table);
594                }
595            }
596        }
597    }
598
599    /**
600     * Reset all database tables to default values.
601     * @static
602     * @return bool true if reset done, false if skipped
603     */
604    public static function reset_database() {
605        global $DB;
606
607        $tables = $DB->get_tables(false);
608        if (!$tables or empty($tables['config'])) {
609            // not installed yet
610            return false;
611        }
612
613        if (!$data = self::get_tabledata()) {
614            // not initialised yet
615            return false;
616        }
617        if (!$structure = self::get_tablestructure()) {
618            // not initialised yet
619            return false;
620        }
621
622        $empties = array();
623        // Use local copy of self::$tableupdated, as list gets updated in for loop.
624        $updatedtables = self::$tableupdated;
625
626        // If empty tablesequences list then it's the very first run.
627        if (empty(self::$tablesequences) && (($DB->get_dbfamily() != 'mysql') && ($DB->get_dbfamily() != 'postgres'))) {
628            // Only Mysql and Postgres support random sequence, so don't guess, just reset everything on very first run.
629            $empties = self::guess_unmodified_empty_tables();
630        }
631
632        // Check if any table has been modified by behat selenium process.
633        if (defined('BEHAT_SITE_RUNNING')) {
634            // Crazy way to reset :(.
635            $tablesupdatedfile = self::get_tables_updated_by_scenario_list_path();
636            if ($tablesupdated = @json_decode(file_get_contents($tablesupdatedfile), true)) {
637                self::$tableupdated = array_merge(self::$tableupdated, $tablesupdated);
638                unlink($tablesupdatedfile);
639            }
640            $updatedtables = self::$tableupdated;
641        }
642
643        $borkedmysql = false;
644        if ($DB->get_dbfamily() === 'mysql') {
645            $version = $DB->get_server_info();
646            if (version_compare($version['version'], '5.6.0') == 1 and version_compare($version['version'], '5.6.16') == -1) {
647                // Everything that comes from Oracle is evil!
648                //
649                // See http://dev.mysql.com/doc/refman/5.6/en/alter-table.html
650                // You cannot reset the counter to a value less than or equal to to the value that is currently in use.
651                //
652                // From 5.6.16 release notes:
653                //   InnoDB: The ALTER TABLE INPLACE algorithm would fail to decrease the auto-increment value.
654                //           (Bug #17250787, Bug #69882)
655                $borkedmysql = true;
656
657            } else if (version_compare($version['version'], '10.0.0') == 1) {
658                // And MariaDB is no better!
659                // Let's hope they pick the patch sometime later...
660                $borkedmysql = true;
661            }
662        }
663
664        if ($borkedmysql) {
665            $mysqlsequences = array();
666            $prefix = $DB->get_prefix();
667            $rs = $DB->get_recordset_sql("SHOW TABLE STATUS LIKE ?", array($prefix.'%'));
668            foreach ($rs as $info) {
669                $table = strtolower($info->name);
670                if (strpos($table, $prefix) !== 0) {
671                    // Incorrect table match caused by _ char.
672                    continue;
673                }
674                if (!is_null($info->auto_increment)) {
675                    $table = preg_replace('/^'.preg_quote($prefix, '/').'/', '', $table);
676                    $mysqlsequences[$table] = $info->auto_increment;
677                }
678            }
679            $rs->close();
680        }
681
682        foreach ($data as $table => $records) {
683            // If table is not modified then no need to do anything.
684            // $updatedtables tables is set after the first run, so check before checking for specific table update.
685            if (!empty($updatedtables) && !isset($updatedtables[$table])) {
686                continue;
687            }
688
689            if ($borkedmysql) {
690                if (empty($records)) {
691                    if (!isset($empties[$table])) {
692                        // Table has been modified and is not empty.
693                        $DB->delete_records($table, null);
694                    }
695                    continue;
696                }
697
698                if (isset($structure[$table]['id']) and $structure[$table]['id']->auto_increment) {
699                    $current = $DB->get_records($table, array(), 'id ASC');
700                    if ($current == $records) {
701                        if (isset($mysqlsequences[$table]) and $mysqlsequences[$table] == $structure[$table]['id']->auto_increment) {
702                            continue;
703                        }
704                    }
705                }
706
707                // Use TRUNCATE as a workaround and reinsert everything.
708                $DB->delete_records($table, null);
709                foreach ($records as $record) {
710                    $DB->import_record($table, $record, false, true);
711                }
712                continue;
713            }
714
715            if (empty($records)) {
716                if (!isset($empties[$table])) {
717                    // Table has been modified and is not empty.
718                    $DB->delete_records($table, array());
719                }
720                continue;
721            }
722
723            if (isset($structure[$table]['id']) and $structure[$table]['id']->auto_increment) {
724                $currentrecords = $DB->get_records($table, array(), 'id ASC');
725                $changed = false;
726                foreach ($records as $id => $record) {
727                    if (!isset($currentrecords[$id])) {
728                        $changed = true;
729                        break;
730                    }
731                    if ((array)$record != (array)$currentrecords[$id]) {
732                        $changed = true;
733                        break;
734                    }
735                    unset($currentrecords[$id]);
736                }
737                if (!$changed) {
738                    if ($currentrecords) {
739                        $lastrecord = end($records);
740                        $DB->delete_records_select($table, "id > ?", array($lastrecord->id));
741                        continue;
742                    } else {
743                        continue;
744                    }
745                }
746            }
747
748            $DB->delete_records($table, array());
749            foreach ($records as $record) {
750                $DB->import_record($table, $record, false, true);
751            }
752        }
753
754        // reset all next record ids - aka sequences
755        self::reset_all_database_sequences($empties);
756
757        // remove extra tables
758        foreach ($tables as $table) {
759            if (!isset($data[$table])) {
760                $DB->get_manager()->drop_table(new xmldb_table($table));
761            }
762        }
763
764        self::reset_updated_table_list();
765
766        return true;
767    }
768
769    /**
770     * Purge dataroot directory
771     * @static
772     * @return void
773     */
774    public static function reset_dataroot() {
775        global $CFG;
776
777        $childclassname = self::get_framework() . '_util';
778
779        // Do not delete automatically installed files.
780        self::skip_original_data_files($childclassname);
781
782        // Clear file status cache, before checking file_exists.
783        clearstatcache();
784
785        // Clean up the dataroot folder.
786        $handle = opendir(self::get_dataroot());
787        while (false !== ($item = readdir($handle))) {
788            if (in_array($item, $childclassname::$datarootskiponreset)) {
789                continue;
790            }
791            if (is_dir(self::get_dataroot()."/$item")) {
792                remove_dir(self::get_dataroot()."/$item", false);
793            } else {
794                unlink(self::get_dataroot()."/$item");
795            }
796        }
797        closedir($handle);
798
799        // Clean up the dataroot/filedir folder.
800        if (file_exists(self::get_dataroot() . '/filedir')) {
801            $handle = opendir(self::get_dataroot() . '/filedir');
802            while (false !== ($item = readdir($handle))) {
803                if (in_array('filedir' . DIRECTORY_SEPARATOR . $item, $childclassname::$datarootskiponreset)) {
804                    continue;
805                }
806                if (is_dir(self::get_dataroot()."/filedir/$item")) {
807                    remove_dir(self::get_dataroot()."/filedir/$item", false);
808                } else {
809                    unlink(self::get_dataroot()."/filedir/$item");
810                }
811            }
812            closedir($handle);
813        }
814
815        make_temp_directory('');
816        make_backup_temp_directory('');
817        make_cache_directory('');
818        make_localcache_directory('');
819        // Purge all data from the caches. This is required for consistency between tests.
820        // Any file caches that happened to be within the data root will have already been clearer (because we just deleted cache)
821        // and now we will purge any other caches as well.  This must be done before the cache_factory::reset() as that
822        // removes all definitions of caches and purge does not have valid caches to operate on.
823        cache_helper::purge_all();
824        // Reset the cache API so that it recreates it's required directories as well.
825        cache_factory::reset();
826    }
827
828    /**
829     * Gets a text-based site version description.
830     *
831     * @return string The site info
832     */
833    public static function get_site_info() {
834        global $CFG;
835
836        $output = '';
837
838        // All developers have to understand English, do not localise!
839        $env = self::get_environment();
840
841        $output .= "Moodle ".$env['moodleversion'];
842        if ($hash = self::get_git_hash()) {
843            $output .= ", $hash";
844        }
845        $output .= "\n";
846
847        // Add php version.
848        require_once($CFG->libdir.'/environmentlib.php');
849        $output .= "Php: ". normalize_version($env['phpversion']);
850
851        // Add database type and version.
852        $output .= ", " . $env['dbtype'] . ": " . $env['dbversion'];
853
854        // OS details.
855        $output .= ", OS: " . $env['os'] . "\n";
856
857        return $output;
858    }
859
860    /**
861     * Try to get current git hash of the Moodle in $CFG->dirroot.
862     * @return string null if unknown, sha1 hash if known
863     */
864    public static function get_git_hash() {
865        global $CFG;
866
867        // This is a bit naive, but it should mostly work for all platforms.
868
869        if (!file_exists("$CFG->dirroot/.git/HEAD")) {
870            return null;
871        }
872
873        $headcontent = file_get_contents("$CFG->dirroot/.git/HEAD");
874        if ($headcontent === false) {
875            return null;
876        }
877
878        $headcontent = trim($headcontent);
879
880        // If it is pointing to a hash we return it directly.
881        if (strlen($headcontent) === 40) {
882            return $headcontent;
883        }
884
885        if (strpos($headcontent, 'ref: ') !== 0) {
886            return null;
887        }
888
889        $ref = substr($headcontent, 5);
890
891        if (!file_exists("$CFG->dirroot/.git/$ref")) {
892            return null;
893        }
894
895        $hash = file_get_contents("$CFG->dirroot/.git/$ref");
896
897        if ($hash === false) {
898            return null;
899        }
900
901        $hash = trim($hash);
902
903        if (strlen($hash) != 40) {
904            return null;
905        }
906
907        return $hash;
908    }
909
910    /**
911     * Set state of modified tables.
912     *
913     * @param string $sql sql which is updating the table.
914     */
915    public static function set_table_modified_by_sql($sql) {
916        global $DB;
917
918        $prefix = $DB->get_prefix();
919
920        preg_match('/( ' . $prefix . '\w*)(.*)/', $sql, $matches);
921        // Ignore random sql for testing like "XXUPDATE SET XSSD".
922        if (!empty($matches[1])) {
923            $table = trim($matches[1]);
924            $table = preg_replace('/^' . preg_quote($prefix, '/') . '/', '', $table);
925            self::$tableupdated[$table] = true;
926
927            if (defined('BEHAT_SITE_RUNNING')) {
928                $tablesupdatedfile = self::get_tables_updated_by_scenario_list_path();
929                $tablesupdated = @json_decode(file_get_contents($tablesupdatedfile), true);
930                if (!isset($tablesupdated[$table])) {
931                    $tablesupdated[$table] = true;
932                    @file_put_contents($tablesupdatedfile, json_encode($tablesupdated, JSON_PRETTY_PRINT));
933                }
934            }
935        }
936    }
937
938    /**
939     * Reset updated table list. This should be done after every reset.
940     */
941    public static function reset_updated_table_list() {
942        self::$tableupdated = array();
943    }
944
945    /**
946     * Delete tablesupdatedbyscenario file. This should be called before suite,
947     * to ensure full db reset.
948     */
949    public static function clean_tables_updated_by_scenario_list() {
950        $tablesupdatedfile = self::get_tables_updated_by_scenario_list_path();
951        if (file_exists($tablesupdatedfile)) {
952            unlink($tablesupdatedfile);
953        }
954
955        // Reset static cache of cli process.
956        self::reset_updated_table_list();
957    }
958
959    /**
960     * Returns the path to the file which holds list of tables updated in scenario.
961     * @return string
962     */
963    protected final static function get_tables_updated_by_scenario_list_path() {
964        return self::get_dataroot() . '/tablesupdatedbyscenario.json';
965    }
966
967    /**
968     * Drop the whole test database
969     * @static
970     * @param bool $displayprogress
971     */
972    protected static function drop_database($displayprogress = false) {
973        global $DB;
974
975        $tables = $DB->get_tables(false);
976        if (isset($tables['config'])) {
977            // config always last to prevent problems with interrupted drops!
978            unset($tables['config']);
979            $tables['config'] = 'config';
980        }
981
982        if ($displayprogress) {
983            echo "Dropping tables:\n";
984        }
985        $dotsonline = 0;
986        foreach ($tables as $tablename) {
987            $table = new xmldb_table($tablename);
988            $DB->get_manager()->drop_table($table);
989
990            if ($dotsonline == 60) {
991                if ($displayprogress) {
992                    echo "\n";
993                }
994                $dotsonline = 0;
995            }
996            if ($displayprogress) {
997                echo '.';
998            }
999            $dotsonline += 1;
1000        }
1001        if ($displayprogress) {
1002            echo "\n";
1003        }
1004    }
1005
1006    /**
1007     * Drops the test framework dataroot
1008     * @static
1009     */
1010    protected static function drop_dataroot() {
1011        global $CFG;
1012
1013        $framework = self::get_framework();
1014        $childclassname = $framework . '_util';
1015
1016        $files = scandir(self::get_dataroot() . '/'  . $framework);
1017        foreach ($files as $file) {
1018            if (in_array($file, $childclassname::$datarootskipondrop)) {
1019                continue;
1020            }
1021            $path = self::get_dataroot() . '/' . $framework . '/' . $file;
1022            if (is_dir($path)) {
1023                remove_dir($path, false);
1024            } else {
1025                unlink($path);
1026            }
1027        }
1028
1029        $jsonfilepath = self::get_dataroot() . '/' . self::$originaldatafilesjson;
1030        if (file_exists($jsonfilepath)) {
1031            // Delete the json file.
1032            unlink($jsonfilepath);
1033            // Delete the dataroot filedir.
1034            remove_dir(self::get_dataroot() . '/filedir', false);
1035        }
1036    }
1037
1038    /**
1039     * Skip the original dataroot files to not been reset.
1040     *
1041     * @static
1042     * @param string $utilclassname the util class name..
1043     */
1044    protected static function skip_original_data_files($utilclassname) {
1045        $jsonfilepath = self::get_dataroot() . '/' . self::$originaldatafilesjson;
1046        if (file_exists($jsonfilepath)) {
1047
1048            $listfiles = file_get_contents($jsonfilepath);
1049
1050            // Mark each files as to not be reset.
1051            if (!empty($listfiles) && !self::$originaldatafilesjsonadded) {
1052                $originaldatarootfiles = json_decode($listfiles);
1053                // Keep the json file. Only drop_dataroot() should delete it.
1054                $originaldatarootfiles[] = self::$originaldatafilesjson;
1055                $utilclassname::$datarootskiponreset = array_merge($utilclassname::$datarootskiponreset,
1056                    $originaldatarootfiles);
1057                self::$originaldatafilesjsonadded = true;
1058            }
1059        }
1060    }
1061
1062    /**
1063     * Save the list of the original dataroot files into a json file.
1064     */
1065    protected static function save_original_data_files() {
1066        global $CFG;
1067
1068        $jsonfilepath = self::get_dataroot() . '/' . self::$originaldatafilesjson;
1069
1070        // Save the original dataroot files if not done (only executed the first time).
1071        if (!file_exists($jsonfilepath)) {
1072
1073            $listfiles = array();
1074            $currentdir = 'filedir' . DIRECTORY_SEPARATOR . '.';
1075            $parentdir = 'filedir' . DIRECTORY_SEPARATOR . '..';
1076            $listfiles[$currentdir] = $currentdir;
1077            $listfiles[$parentdir] = $parentdir;
1078
1079            $filedir = self::get_dataroot() . '/filedir';
1080            if (file_exists($filedir)) {
1081                $directory = new RecursiveDirectoryIterator($filedir);
1082                foreach (new RecursiveIteratorIterator($directory) as $file) {
1083                    if ($file->isDir()) {
1084                        $key = substr($file->getPath(), strlen(self::get_dataroot() . '/'));
1085                    } else {
1086                        $key = substr($file->getPathName(), strlen(self::get_dataroot() . '/'));
1087                    }
1088                    $listfiles[$key] = $key;
1089                }
1090            }
1091
1092            // Save the file list in a JSON file.
1093            $fp = fopen($jsonfilepath, 'w');
1094            fwrite($fp, json_encode(array_values($listfiles)));
1095            fclose($fp);
1096        }
1097    }
1098
1099    /**
1100     * Return list of environment versions on which tests will run.
1101     * Environment includes:
1102     * - moodleversion
1103     * - phpversion
1104     * - dbtype
1105     * - dbversion
1106     * - os
1107     *
1108     * @return array
1109     */
1110    public static function get_environment() {
1111        global $CFG, $DB;
1112
1113        $env = array();
1114
1115        // Add moodle version.
1116        $release = null;
1117        require("$CFG->dirroot/version.php");
1118        $env['moodleversion'] = $release;
1119
1120        // Add php version.
1121        $phpversion = phpversion();
1122        $env['phpversion'] = $phpversion;
1123
1124        // Add database type and version.
1125        $dbtype = $CFG->dbtype;
1126        $dbinfo = $DB->get_server_info();
1127        $dbversion = $dbinfo['version'];
1128        $env['dbtype'] = $dbtype;
1129        $env['dbversion'] = $dbversion;
1130
1131        // OS details.
1132        $osdetails = php_uname('s') . " " . php_uname('r') . " " . php_uname('m');
1133        $env['os'] = $osdetails;
1134
1135        return $env;
1136    }
1137}
1138