MDL-69068 behat: New step to support singular generators
[moodle.git] / lib / behat / classes / behat_generator_base.php
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/>.
17 /**
18  * Base class for data generators component support for acceptance testing.
19  *
20  * @package   core
21  * @category  test
22  * @copyright 2012 David MonllaĆ³
23  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 defined('MOODLE_INTERNAL') || die();
28 use Behat\Gherkin\Node\TableNode as TableNode;
29 use Behat\Behat\Tester\Exception\PendingException as PendingException;
32 /**
33  * Class to quickly create Behat test data using component data generators.
34  *
35  * There is a subclass of class for each component that wants to be able to
36  * generate entities using the Behat step
37  *     Given the following "entity types" exist:
38  *       | test | data |
39  *
40  * For core entities, the entity type is like "courses" or "users" and
41  * generating those is handled by behat_core_generator. For other components
42  * the entity type is like "mod_quiz > User override" and that is handled by
43  * behat_mod_quiz_generator defined in mod/quiz/tests/generator/behat_mod_quiz_generator.php.
44  *
45  * The types of entities that can be generated are described by the array returned
46  * by the {@link get_generateable_entities()} method. The list in
47  * {@link behat_core_generator} is a good (if complex) example.
48  *
49  * How things work is best explained with a few examples. All this is implemented
50  * in the {@link generate_items()} method below, if you want to see every detail of
51  * how it works.
52  *
53  * Simple example from behat_core_generator:
54  * 'users' => [
55  *     'datagenerator' => 'user',
56  *     'required' => ['username'],
57  * ],
58  * The steps performed are:
59  *
60  * 1. 'datagenerator' => 'user' means that the word used in the method names below is 'user'.
61  *
62  * 2. Because 'required' is present, check the supplied data exists 'username' column is present
63  *    in the supplied data table and if not display an error.
64  *
65  * 3. Then for each row in the table as an array $elementdata (array keys are column names)
66  *    and process it as follows
67  *
68  * 4. (Not used in this example.)
69  *
70  * 5. If the method 'preprocess_user' exists, then call it to update $elementdata.
71  *    (It does, in this case it sets the password to the username, if password was not given.)
72  *
73  * We then do one of 4 things:
74  *
75  * 6a. If there is a method 'process_user' we call it. (It doesn't for user,
76  *     but there are other examples like process_enrol_user() in behat_core_generator.)
77  *
78  * 6b. (Not used in this example.)
79  *
80  * 6c. Else, if testing_data_generator::create_user exists, we call it with $elementdata. (it does.)
81  *
82  * 6d. If none of these three things work. an error is thrown.
83  *
84  * To understand the missing steps above, consider the example from behat_mod_quiz_generator:
85  * 'group override' => [
86  *      'datagenerator' => 'override',
87  *      'required' => ['quiz', 'group'],
88  *      'switchids' => ['quiz' => 'quiz', 'group' => 'groupid'],
89  * ],
90  * Processing is as above, except that:
91  *
92  * 1. Note 'datagenerator' is 'override' (not group_override). 'user override' maps to the
93  *    same datagenerator. This works fine.
94  *
95  * 4. Because 'switchids' is present, human-readable data in the table gets converted to ids.
96  *    They array key 'group' refers to a column which may be present in the table (it will be
97  *    here because it is required, but it does not have to be in general). If that column
98  *    is present and contains a value, then the method matching name like get_group_id() is
99  *    called with the value from that column in the data table. You must implement this
100  *    method. You can see several examples of this sort of method below.
101  *
102  *    If that method returns a group id, then $elementdata['group'] is unset and
103  *    $elementdata['groupid'] is set to the result of the get_group_id() call. 'groupid' here
104  *    because of the definition is 'switchids' => [..., 'group' => 'groupid'].
105  *    If get_group_id() cannot find the group, it should throw a helpful exception.
106  *
107  *    Similarly, 'quiz' (the quiz name) is looked up with a call to get_quiz_id(). Here, the
108  *    new array key set matches the old one removed. This is fine.
109  *
110  * 6b. We are in a plugin, so before checking whether testing_data_generator::create_override
111  *     exists we first check whether mod_quiz_generator::create_override() exists. It does,
112  *     and this is what gets called.
113  *
114  * This second example shows why the get_..._id methods for core entities are in this base
115  * class, not in behat_core_generator. Plugins may need to look up the ids of
116  * core entities.
117  *
118  * behat_core_generator is defined in lib/behat/classes/behat_core_generator.php
119  * and for components, behat_..._generator is defined in tests/generator/behat_..._generator.php
120  * inside the plugin. For example behat_mod_quiz_generator is defined in
121  * mod/quiz/tests/generator/behat_mod_quiz_generator.php.
122  *
123  * @package   core
124  * @category  test
125  * @copyright 2012 David MonllaĆ³
126  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
127  */
128 abstract class behat_generator_base {
130     /**
131      * @var string the name of the component we belong to.
132      *
133      * This should probably only be used to make error messages clearer.
134      */
135     protected $component;
137     /**
138      * @var testing_data_generator the core data generator
139      */
140     protected $datagenerator;
142     /**
143      * @var testing_data_generator the data generator for this component.
144      */
145     protected $componentdatagenerator;
147     /**
148      * Constructor.
149      *
150      * @param string $component component name, to make error messages more readable.
151      */
152     public function __construct(string $component) {
153         $this->component = $component;
154     }
156     /**
157      * Get a list of the entities that can be created for this component.
158      *
159      * This function must be overridden in subclasses. See class comment
160      * above for a description of the data structure.
161      * See {@link behat_core_generator} for an example.
162      *
163      * @return array entity name => information about how to generate.
164      */
165     protected abstract function get_creatable_entities(): array;
167     /**
168      * Do the work to generate an entity.
169      *
170      * This is called by {@link behat_data_generators::the_following_entities_exist()}.
171      *
172      * @param string    $generatortype The name of the entity to create.
173      * @param TableNode $data from the step.
174      * @param bool      $singular Whether there is only one record and it is pivotted
175      */
176     public function generate_items(string $generatortype, TableNode $data, bool $singular = false) {
177         // Now that we need them require the data generators.
178         require_once(__DIR__ . '/../../testing/generator/lib.php');
180         $elements = $this->get_creatable_entities();
182         foreach ($elements as $key => $configuration) {
183             if (array_key_exists('singular', $configuration)) {
184                 $singularverb = $configuration['singular'];
185                 unset($configuration['singular']);
186                 unset($elements[$key]['singular']);
187                 $elements[$singularverb] = $configuration;
188             }
189         }
191         if (!isset($elements[$generatortype])) {
192             throw new PendingException($this->name_for_errors($generatortype) .
193                     ' is not a known type of entity that can be generated.');
194         }
195         $entityinfo = $elements[$generatortype];
197         $this->datagenerator = testing_util::get_data_generator();
198         if ($this->component === 'core') {
199             $this->componentdatagenerator = $this->datagenerator;
200         } else {
201             $this->componentdatagenerator = $this->datagenerator->get_plugin_generator($this->component);
202         }
204         $generatortype = $entityinfo['datagenerator'];
206         if ($singular) {
207             // There is only one record to generate, and the table has been pivotted.
208             // The rows each represent a single field.
209             $rows = [$data->getRowsHash()];
210         } else {
211             // There are multiple records to generate.
212             // The rows represent an item to create.
213             $rows = $data->getHash();
214         }
216         foreach ($rows as $elementdata) {
217             // Check if all the required fields are there.
218             foreach ($entityinfo['required'] as $requiredfield) {
219                 if (!isset($elementdata[$requiredfield])) {
220                     throw new Exception($this->name_for_errors($generatortype) .
221                             ' requires the field ' . $requiredfield . ' to be specified');
222                 }
223             }
225             // Switch from human-friendly references to ids.
226             if (!empty($entityinfo['switchids'])) {
227                 foreach ($entityinfo['switchids'] as $element => $field) {
228                     $methodname = 'get_' . $element . '_id';
230                     // Not all the switch fields are required, default vars will be assigned by data generators.
231                     if (isset($elementdata[$element])) {
232                         if (!method_exists($this, $methodname)) {
233                             throw new coding_exception('The generator for ' .
234                                     $this->name_for_errors($generatortype) .
235                                     ' entities specifies \'switchids\' => [..., \'' . $element .
236                                     '\' => \'' . $field . '\', ...] but the required method ' .
237                                     $methodname . '() has not been defined in ' .
238                                     get_class($this) . '.');
239                         }
240                         // Temp $id var to avoid problems when $element == $field.
241                         $id = $this->{$methodname}($elementdata[$element]);
242                         unset($elementdata[$element]);
243                         $elementdata[$field] = $id;
244                     }
245                 }
246             }
248             // Preprocess the entities that requires a special treatment.
249             if (method_exists($this, 'preprocess_' . $generatortype)) {
250                 $elementdata = $this->{'preprocess_' . $generatortype}($elementdata);
251             }
253             // Creates element.
254             if (method_exists($this, 'process_' . $generatortype)) {
255                 // Use a method on this class to do the work.
256                 $this->{'process_' . $generatortype}($elementdata);
258             } else if (method_exists($this->componentdatagenerator, 'create_' . $generatortype)) {
259                 // Using the component't own data generator if it exists.
260                 $this->componentdatagenerator->{'create_' . $generatortype}($elementdata);
262             } else if (method_exists($this->datagenerator, 'create_' . $generatortype)) {
263                 // Use a method on the core data geneator, if there is one.
264                 $this->datagenerator->{'create_' . $generatortype}($elementdata);
266             } else {
267                 // Give up.
268                 throw new PendingException($this->name_for_errors($generatortype) .
269                         ' data generator is not implemented');
270             }
271         }
272     }
274     /**
275      * Helper for formatting error messages.
276      *
277      * @param string $entitytype entity type without prefix, e.g. 'frog'.
278      * @return string either 'frog' for core entities, or 'mod_mymod > frog' for components.
279      */
280     protected function name_for_errors(string $entitytype): string {
281         if ($this->component === 'core') {
282             return '"' . $entitytype . '"';
283         } else {
284             return '"' . $this->component . ' > ' . $entitytype . '"';
285         }
286     }
288     /**
289      * Gets the grade category id from the grade category fullname
290      *
291      * @param string $fullname the grade category name.
292      * @return int corresponding id.
293      */
294     protected function get_gradecategory_id($fullname) {
295         global $DB;
297         if (!$id = $DB->get_field('grade_categories', 'id', array('fullname' => $fullname))) {
298             throw new Exception('The specified grade category with fullname "' . $fullname . '" does not exist');
299         }
300         return $id;
301     }
303     /**
304      * Gets the user id from it's username.
305      * @throws Exception
306      * @param string $username
307      * @return int
308      */
309     protected function get_user_id($username) {
310         global $DB;
312         if (!$id = $DB->get_field('user', 'id', array('username' => $username))) {
313             throw new Exception('The specified user with username "' . $username . '" does not exist');
314         }
315         return $id;
316     }
318     /**
319      * Gets the role id from it's shortname.
320      * @throws Exception
321      * @param string $roleshortname
322      * @return int
323      */
324     protected function get_role_id($roleshortname) {
325         global $DB;
327         if (!$id = $DB->get_field('role', 'id', array('shortname' => $roleshortname))) {
328             throw new Exception('The specified role with shortname "' . $roleshortname . '" does not exist');
329         }
331         return $id;
332     }
334     /**
335      * Gets the category id from it's idnumber.
336      * @throws Exception
337      * @param string $idnumber
338      * @return int
339      */
340     protected function get_category_id($idnumber) {
341         global $DB;
343         // If no category was specified use the data generator one.
344         if ($idnumber == false) {
345             return null;
346         }
348         if (!$id = $DB->get_field('course_categories', 'id', array('idnumber' => $idnumber))) {
349             throw new Exception('The specified category with idnumber "' . $idnumber . '" does not exist');
350         }
352         return $id;
353     }
355     /**
356      * Gets the course id from it's shortname.
357      * @throws Exception
358      * @param string $shortname
359      * @return int
360      */
361     protected function get_course_id($shortname) {
362         global $DB;
364         if (!$id = $DB->get_field('course', 'id', array('shortname' => $shortname))) {
365             throw new Exception('The specified course with shortname "' . $shortname . '" does not exist');
366         }
367         return $id;
368     }
370     /**
371      * Gets the group id from it's idnumber.
372      * @throws Exception
373      * @param string $idnumber
374      * @return int
375      */
376     protected function get_group_id($idnumber) {
377         global $DB;
379         if (!$id = $DB->get_field('groups', 'id', array('idnumber' => $idnumber))) {
380             throw new Exception('The specified group with idnumber "' . $idnumber . '" does not exist');
381         }
382         return $id;
383     }
385     /**
386      * Gets the grouping id from it's idnumber.
387      * @throws Exception
388      * @param string $idnumber
389      * @return int
390      */
391     protected function get_grouping_id($idnumber) {
392         global $DB;
394         // Do not fetch grouping ID for empty grouping idnumber.
395         if (empty($idnumber)) {
396             return null;
397         }
399         if (!$id = $DB->get_field('groupings', 'id', array('idnumber' => $idnumber))) {
400             throw new Exception('The specified grouping with idnumber "' . $idnumber . '" does not exist');
401         }
402         return $id;
403     }
405     /**
406      * Gets the cohort id from it's idnumber.
407      * @throws Exception
408      * @param string $idnumber
409      * @return int
410      */
411     protected function get_cohort_id($idnumber) {
412         global $DB;
414         if (!$id = $DB->get_field('cohort', 'id', array('idnumber' => $idnumber))) {
415             throw new Exception('The specified cohort with idnumber "' . $idnumber . '" does not exist');
416         }
417         return $id;
418     }
420     /**
421      * Gets the outcome item id from its shortname.
422      * @throws Exception
423      * @param string $shortname
424      * @return int
425      */
426     protected function get_outcome_id($shortname) {
427         global $DB;
429         if (!$id = $DB->get_field('grade_outcomes', 'id', array('shortname' => $shortname))) {
430             throw new Exception('The specified outcome with shortname "' . $shortname . '" does not exist');
431         }
432         return $id;
433     }
435     /**
436      * Get the id of a named scale.
437      * @param string $name the name of the scale.
438      * @return int the scale id.
439      */
440     protected function get_scale_id($name) {
441         global $DB;
443         if (!$id = $DB->get_field('scale', 'id', array('name' => $name))) {
444             throw new Exception('The specified scale with name "' . $name . '" does not exist');
445         }
446         return $id;
447     }
449     /**
450      * Get the id of a named question category (must be globally unique).
451      * Note that 'Top' is a special value, used when setting the parent of another
452      * category, meaning top-level.
453      *
454      * @param string $name the question category name.
455      * @return int the question category id.
456      */
457     protected function get_questioncategory_id($name) {
458         global $DB;
460         if ($name == 'Top') {
461             return 0;
462         }
464         if (!$id = $DB->get_field('question_categories', 'id', array('name' => $name))) {
465             throw new Exception('The specified question category with name "' . $name . '" does not exist');
466         }
467         return $id;
468     }
470     /**
471      * Gets the internal context id from the context reference.
472      *
473      * The context reference changes depending on the context
474      * level, it can be the system, a user, a category, a course or
475      * a module.
476      *
477      * @throws Exception
478      * @param string $levelname The context level string introduced by the test writer
479      * @param string $contextref The context reference introduced by the test writer
480      * @return context
481      */
482     protected function get_context($levelname, $contextref) {
483         global $DB;
485         // Getting context levels and names (we will be using the English ones as it is the test site language).
486         $contextlevels = context_helper::get_all_levels();
487         $contextnames = array();
488         foreach ($contextlevels as $level => $classname) {
489             $contextnames[context_helper::get_level_name($level)] = $level;
490         }
492         if (empty($contextnames[$levelname])) {
493             throw new Exception('The specified "' . $levelname . '" context level does not exist');
494         }
495         $contextlevel = $contextnames[$levelname];
497         // Return it, we don't need to look for other internal ids.
498         if ($contextlevel == CONTEXT_SYSTEM) {
499             return context_system::instance();
500         }
502         switch ($contextlevel) {
504             case CONTEXT_USER:
505                 $instanceid = $DB->get_field('user', 'id', array('username' => $contextref));
506                 break;
508             case CONTEXT_COURSECAT:
509                 $instanceid = $DB->get_field('course_categories', 'id', array('idnumber' => $contextref));
510                 break;
512             case CONTEXT_COURSE:
513                 $instanceid = $DB->get_field('course', 'id', array('shortname' => $contextref));
514                 break;
516             case CONTEXT_MODULE:
517                 $instanceid = $DB->get_field('course_modules', 'id', array('idnumber' => $contextref));
518                 break;
520             default:
521                 break;
522         }
524         $contextclass = $contextlevels[$contextlevel];
525         if (!$context = $contextclass::instance($instanceid, IGNORE_MISSING)) {
526             throw new Exception('The specified "' . $contextref . '" context reference does not exist');
527         }
529         return $context;
530     }
532     /**
533      * Gets the contact id from it's username.
534      * @throws Exception
535      * @param string $username
536      * @return int
537      */
538     protected function get_contact_id($username) {
539         global $DB;
541         if (!$id = $DB->get_field('user', 'id', array('username' => $username))) {
542             throw new Exception('The specified user with username "' . $username . '" does not exist');
543         }
544         return $id;
545     }
547     /**
548      * Gets the external backpack id from it's backpackweburl.
549      * @param string $backpackweburl
550      * @return mixed
551      * @throws dml_exception
552      */
553     protected function get_externalbackpack_id($backpackweburl) {
554         global $DB;
555         if (!$id = $DB->get_field('badge_external_backpack', 'id', ['backpackweburl' => $backpackweburl])) {
556             throw new Exception('The specified external backpack with backpackweburl "' . $username . '" does not exist');
557         }
558         return $id;
559     }