From 39e777e3f7c27b75aee4dd688611d4bbfcca35b5 Mon Sep 17 00:00:00 2001 From: Ruslan Kabalin Date: Fri, 1 May 2020 17:19:55 +0100 Subject: [PATCH 1/4] MDL-67548 core_course: Remove category deletion form artefact. This is not usable for the reason that categorylabel element does not exist. --- course/classes/deletecategory_form.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/course/classes/deletecategory_form.php b/course/classes/deletecategory_form.php index b75f19f0fc8..285047bf100 100644 --- a/course/classes/deletecategory_form.php +++ b/course/classes/deletecategory_form.php @@ -111,10 +111,6 @@ class core_course_deletecategory_form extends moodleform { $mform->setType('categoryid', PARAM_ALPHANUM); $mform->addElement('hidden', 'action', 'deletecategory'); $mform->setType('action', PARAM_ALPHANUM); - $mform->addElement('hidden', 'sure'); - // This gets set by default to ensure that if the user changes it manually we can detect it. - $mform->setDefault('sure', md5(serialize($this->coursecat))); - $mform->setType('sure', PARAM_ALPHANUM); $this->add_action_buttons(true, get_string('delete')); } @@ -133,10 +129,6 @@ class core_course_deletecategory_form extends moodleform { $errors['newparent'] = get_string('required'); } - if ($data['sure'] !== md5(serialize($this->coursecat))) { - $errors['categorylabel'] = get_string('categorymodifiedcancel'); - } - return $errors; } } \ No newline at end of file From 27b29b29962b8291880a939134a6f4a260047d6b Mon Sep 17 00:00:00 2001 From: Ruslan Kabalin Date: Wed, 6 May 2020 09:43:42 +0100 Subject: [PATCH 2/4] MDL-67548 core_course: Reflect content move category in deleted event. --- course/classes/category.php | 2 +- lib/classes/event/course_category_deleted.php | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/course/classes/category.php b/course/classes/category.php index aea6ed0df83..7963c6fc8e9 100644 --- a/course/classes/category.php +++ b/course/classes/category.php @@ -2169,7 +2169,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr $event = \core\event\course_category_deleted::create(array( 'objectid' => $this->id, 'context' => $context, - 'other' => array('name' => $this->name) + 'other' => array('name' => $this->name, 'contentmovedcategoryid' => $newparentid) )); $event->set_coursecat($this); $event->trigger(); diff --git a/lib/classes/event/course_category_deleted.php b/lib/classes/event/course_category_deleted.php index 2e5c73a9d50..bac3029b1f9 100644 --- a/lib/classes/event/course_category_deleted.php +++ b/lib/classes/event/course_category_deleted.php @@ -33,6 +33,7 @@ defined('MOODLE_INTERNAL') || die(); * Extra information about event. * * - string name: category name. + * - string contentmovedcategoryid: (optional) category id where content was moved on deletion * } * * @package core @@ -71,7 +72,11 @@ class course_category_deleted extends base { * @return string */ public function get_description() { - return "The user with id '$this->userid' deleted the course category with id '$this->objectid'."; + $descr = "The user with id '$this->userid' deleted the course category with id '$this->objectid'."; + if (!empty($this->other['contentmovedcategoryid'])) { + $descr .= " Its content has been moved to category with id '{$this->other['contentmovedcategoryid']}'."; + } + return $descr; } /** From 72316a9f4eaf33ed5cbdacc79a4cb53971e74bc1 Mon Sep 17 00:00:00 2001 From: Ruslan Kabalin Date: Fri, 1 May 2020 18:00:40 +0100 Subject: [PATCH 3/4] MDL-67548 core_course: Add more category deletion hooks. Introduce new hooks for plugin developers: - _can_course_category_delete($category) - _can_course_category_delete_move($category, $newcategory) These hooks allow plugin developers greater control over category deletion. Plugin can return false in those functions if category deletion or deletion with content move to the new parent category is not permitted. - _pre_course_category_delete_move($category, $newcategory) This hook is expanding functionality of existing _pre_course_category_delete hook and allow plugin developers to execute code prior to category deletion when its content is moved to another category. - _get_course_category_contents($category) This hook allow plugin developers to add information that is displayed on category deletion form. Function should return string, which will be added to the list of category contents shown on the form. --- course/classes/category.php | 72 ++++++++++++++++++++------ course/classes/deletecategory_form.php | 22 ++++++-- lang/en/error.php | 1 + lib/upgrade.txt | 14 +++++ 4 files changed, 89 insertions(+), 20 deletions(-) diff --git a/course/classes/category.php b/course/classes/category.php index 7963c6fc8e9..ebc2ebf79bb 100644 --- a/course/classes/category.php +++ b/course/classes/category.php @@ -171,6 +171,24 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr debugging('Can not unset core_course_category instance properties!', DEBUG_DEVELOPER); } + /** + * Get list of plugin callback functions. + * + * @param string $name Callback function name. + * @return [callable] $pluginfunctions + */ + public function get_plugins_callback_function(string $name) : array { + $pluginfunctions = []; + if ($pluginsfunction = get_plugins_with_function($name)) { + foreach ($pluginsfunction as $plugintype => $plugins) { + foreach ($plugins as $pluginfunction) { + $pluginfunctions[] = $pluginfunction; + } + } + } + return $pluginfunctions; + } + /** * Create an iterator because magic vars can't be seen by 'foreach'. * @@ -1900,13 +1918,12 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr return false; } - $context = $this->get_context(); - if (!$this->is_uservisible() || - !has_capability('moodle/category:manage', $context)) { + if (!$this->has_manage_capability()) { return false; } // Check all child categories (not only direct children). + $context = $this->get_context(); $sql = context_helper::get_preload_record_columns_sql('ctx'); $childcategories = $DB->get_records_sql('SELECT c.id, c.visible, '. $sql. ' FROM {context} ctx '. @@ -1936,6 +1953,15 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr } } + // Check if plugins permit deletion of category content. + $pluginfunctions = $this->get_plugins_callback_function('can_course_category_delete'); + foreach ($pluginfunctions as $pluginfunction) { + // If at least one plugin does not permit deletion, stop and return false. + if (!$pluginfunction($this)) { + return false; + } + } + return true; } @@ -1961,13 +1987,9 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr $settimeout = core_php_time_limit::raise(); // Allow plugins to use this category before we completely delete it. - if ($pluginsfunction = get_plugins_with_function('pre_course_category_delete')) { - $category = $this->get_db_record(); - foreach ($pluginsfunction as $plugintype => $plugins) { - foreach ($plugins as $pluginfunction) { - $pluginfunction($category); - } - } + $pluginfunctions = $this->get_plugins_callback_function('pre_course_category_delete'); + foreach ($pluginfunctions as $pluginfunction) { + $pluginfunction($this->get_db_record()); } $deletedcourses = array(); @@ -2072,25 +2094,35 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr public function can_move_content_to($newcatid) { global $CFG; require_once($CFG->libdir . '/questionlib.php'); - $context = $this->get_context(); - if (!$this->is_uservisible() || - !has_capability('moodle/category:manage', $context)) { + + if (!$this->has_manage_capability()) { return false; } + $testcaps = array(); // If this category has courses in it, user must have 'course:create' capability in target category. if ($this->has_courses()) { $testcaps[] = 'moodle/course:create'; } // If this category has subcategories or questions, user must have 'category:manage' capability in target category. - if ($this->has_children() || question_context_has_any_questions($context)) { + if ($this->has_children() || question_context_has_any_questions($this->get_context())) { $testcaps[] = 'moodle/category:manage'; } - if (!empty($testcaps)) { - return has_all_capabilities($testcaps, context_coursecat::instance($newcatid)); + if (!empty($testcaps) && !has_all_capabilities($testcaps, context_coursecat::instance($newcatid))) { + // No sufficient capabilities to perform this task. + return false; + } + + // Check if plugins permit moving category content. + $pluginfunctions = $this->get_plugins_callback_function('can_course_category_delete_move'); + $newparentcat = self::get($newcatid, MUST_EXIST, true); + foreach ($pluginfunctions as $pluginfunction) { + // If at least one plugin does not permit move on deletion, stop and return false. + if (!$pluginfunction($this, $newparentcat)) { + return false; + } } - // There is no content but still return true. return true; } @@ -2120,6 +2152,12 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr $coursesids = $DB->get_fieldset_select('course', 'id', 'category = :category ORDER BY sortorder ASC', $params); $context = $this->get_context(); + // Allow plugins to make necessary changes before we move the category content. + $pluginfunctions = $this->get_plugins_callback_function('pre_course_category_delete_move'); + foreach ($pluginfunctions as $pluginfunction) { + $pluginfunction($this, $newparentcat); + } + if ($children) { foreach ($children as $childcat) { $childcat->change_parent_raw($newparentcat); diff --git a/course/classes/deletecategory_form.php b/course/classes/deletecategory_form.php index 285047bf100..ddbd43396ca 100644 --- a/course/classes/deletecategory_form.php +++ b/course/classes/deletecategory_form.php @@ -76,14 +76,23 @@ class core_course_deletecategory_form extends moodleform { // Describe the contents of this category. $contents = ''; if ($this->coursecat->has_children()) { - $contents .= '
  • ' . get_string('subcategories') . '
  • '; + $contents .= html_writer::tag('li', get_string('subcategories')); } if ($this->coursecat->has_courses()) { - $contents .= '
  • ' . get_string('courses') . '
  • '; + $contents .= html_writer::tag('li', get_string('courses')); } if (question_context_has_any_questions($categorycontext)) { - $contents .= '
  • ' . get_string('questionsinthequestionbank') . '
  • '; + $contents .= html_writer::tag('li', get_string('questionsinthequestionbank')); } + + // Check if plugins can provide more info. + $pluginfunctions = $this->coursecat->get_plugins_callback_function('get_course_category_contents'); + foreach ($pluginfunctions as $pluginfunction) { + if ($plugincontents = $pluginfunction($this->coursecat)) { + $contents .= html_writer::tag('li', $plugincontents); + } + } + if (!empty($contents)) { $mform->addElement('static', 'emptymessage', get_string('thiscategorycontains'), html_writer::tag('ul', $contents)); } else { @@ -92,7 +101,9 @@ class core_course_deletecategory_form extends moodleform { // Give the options for what to do. $mform->addElement('select', 'fulldelete', get_string('whattodo'), $options); + if (count($options) == 1) { + // Freeze selector if only one option available. $optionkeys = array_keys($options); $option = reset($optionkeys); $mform->hardFreeze('fulldelete'); @@ -127,6 +138,11 @@ class core_course_deletecategory_form extends moodleform { if (empty($data['fulldelete']) && empty($data['newparent'])) { // When they have chosen the move option, they must specify a destination. $errors['newparent'] = get_string('required'); + return $errors; + } + + if (!empty($data['newparent']) && !$this->coursecat->can_move_content_to($data['newparent'])) { + $errors['newparent'] = get_string('movecatcontentstoselected', 'error'); } return $errors; diff --git a/lang/en/error.php b/lang/en/error.php index cf78c4c5286..5a6c4637c54 100644 --- a/lang/en/error.php +++ b/lang/en/error.php @@ -405,6 +405,7 @@ $string['moduledoesnotexist'] = 'This module does not exist'; $string['moduleinstancedoesnotexist'] = 'The instance of this module does not exist'; $string['modulemissingcode'] = 'Module {$a} is missing the code needed to perform this function'; $string['movecatcontentstoroot'] = 'Moving the category content to root is not allowed. You must move the contents to an existing category!'; +$string['movecatcontentstoselected'] = 'Some of the category content can not be moved into selected category.'; $string['movecategorynotpossible'] = 'You cannot move category \'{$a}\' into the selected category.'; $string['movecategoryownparent'] = 'You cannot make category \'{$a}\' a parent of itself.'; $string['movecategoryparentconflict'] = 'You cannot make category \'{$a}\' a subcategory of one of its own subcategories.'; diff --git a/lib/upgrade.txt b/lib/upgrade.txt index d8ab4bc5ba5..f82110e9e58 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -53,6 +53,20 @@ information provided here is intended especially for developers. The confirmation dialogue no longer has a configurable "No" button as per similar changes in MDL-59759. This set of confirmation modals was unintentionally missed from that deprecation process. * The download_as_dataformat() method has been deprecated. Please use \core\dataformat::download_data() instead +* Introduce new hooks for plugin developers: + - _can_course_category_delete($category) + - _can_course_category_delete_move($category, $newcategory) + These hooks allow plugin developers greater control over category deletion. Plugin can return false in those + functions if category deletion or deletion with content move to the new parent category is not permitted. + Both $category and $newcategory params are instances of core_course_category class. + - _pre_course_category_delete_move($category, $newcategory) + This hook is expanding functionality of existing _pre_course_category_delete hook and allow plugin developers + to execute code prior to category deletion when its content is moved to another category. + Both $category and $newcategory params are instances of core_course_category class. + - _get_course_category_contents($category) + This hook allow plugin developers to add information that is displayed on category deletion form. Function should + return string, which will be added to the list of category contents shown on the form. $category param is an instance + of core_course_category class. === 3.8 === * Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop From a38e1d49684eec37d773bc272ee74f8c8bb5d769 Mon Sep 17 00:00:00 2001 From: Ruslan Kabalin Date: Tue, 12 May 2020 14:29:00 +0100 Subject: [PATCH 4/4] MDL-67548 core_course: Add unittests covering category deletion hooks. --- course/tests/category_hooks_test.php | 215 +++++++++++++++++++++++++++ course/tests/fixtures/mock_hooks.php | 184 +++++++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 course/tests/category_hooks_test.php create mode 100644 course/tests/fixtures/mock_hooks.php diff --git a/course/tests/category_hooks_test.php b/course/tests/category_hooks_test.php new file mode 100644 index 00000000000..52af5f710d7 --- /dev/null +++ b/course/tests/category_hooks_test.php @@ -0,0 +1,215 @@ +. + +/** + * Tests for class core_course_category methods invoking hooks. + * + * @package core_course + * @category test + * @copyright 2020 Ruslan Kabalin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tests\core_course; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/course/tests/fixtures/mock_hooks.php'); + +use PHPUnit\Framework\MockObject\MockObject; + +/** + * Functional test for class core_course_category methods invoking hooks. + */ +class core_course_category_hooks_testcase extends \advanced_testcase { + + protected function setUp() { + $this->resetAfterTest(); + $this->setAdminUser(); + } + + /** + * Provides mocked category configured for named callback function. + * + * get_plugins_callback_function will return callable prefixed with `tool_unittest_`, + * the actual callbacks are defined in mock_hooks.php fixture file. + * + * @param core_course_category $category Category to mock + * @param string $callback Callback function used in method we test. + * @return MockObject + */ + public function get_mock_category(\core_course_category $category, string $callback = '') : MockObject { + // Setup mock object for \core_course_category. + // Disable original constructor, since we can't use it directly since it is private. + $mockcategory = $this->getMockBuilder(\core_course_category::class) + ->setMethods(['get_plugins_callback_function']) + ->disableOriginalConstructor() + ->getMock(); + + // Define get_plugins_callback_function use and return value. + if (!empty($callback)) { + $mockcategory->method('get_plugins_callback_function') + ->with($this->equalTo($callback)) + ->willReturn(['tool_unittest_' . $callback]); + } + + // Modify constructor visibility and invoke mock object with real object. + // This is used to overcome private constructor. + $reflected = new \ReflectionClass(\core_course_category::class); + $constructor = $reflected->getConstructor(); + $constructor->setAccessible(true); + $constructor->invoke($mockcategory, $category->get_db_record()); + + return $mockcategory; + } + + public function test_can_course_category_delete_hook() { + $category1 = \core_course_category::create(array('name' => 'Cat1')); + $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id)); + $category3 = \core_course_category::create(array('name' => 'Cat3')); + + $mockcategory2 = $this->get_mock_category($category2, 'can_course_category_delete'); + + // Add course to mocked clone of category2. + $course1 = $this->getDataGenerator()->create_course(array('category' => $mockcategory2->id)); + + // Now configure fixture to return false for the callback. + mock_hooks::set_can_course_category_delete_return(false); + $this->assertFalse($mockcategory2->can_delete_full($category3->id)); + + // Now configure fixture to return true for the callback. + mock_hooks::set_can_course_category_delete_return(true); + $this->assertTrue($mockcategory2->can_delete_full($category3->id)); + + // Verify passed arguments. + $arguments = mock_hooks::get_calling_arguments(); + $this->assertCount(1, $arguments); + + // Argument 1 is the same core_course_category instance. + $argument = array_shift($arguments); + $this->assertSame($mockcategory2, $argument); + } + + public function test_can_course_category_delete_move_hook() { + $category1 = \core_course_category::create(array('name' => 'Cat1')); + $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id)); + $category3 = \core_course_category::create(array('name' => 'Cat3')); + + $mockcategory2 = $this->get_mock_category($category2, 'can_course_category_delete_move'); + + // Add course to mocked clone of category2. + $course1 = $this->getDataGenerator()->create_course(array('category' => $mockcategory2->id)); + + // Now configure fixture to return false for the callback. + mock_hooks::set_can_course_category_delete_move_return(false); + $this->assertFalse($mockcategory2->can_move_content_to($category3->id)); + + // Now configure fixture to return true for the callback. + mock_hooks::set_can_course_category_delete_move_return(true); + $this->assertTrue($mockcategory2->can_move_content_to($category3->id)); + + // Verify passed arguments. + $arguments = mock_hooks::get_calling_arguments(); + $this->assertCount(2, $arguments); + + // Argument 1 is the same core_course_category instance. + $argument = array_shift($arguments); + $this->assertSame($mockcategory2, $argument); + + // Argument 2 is referring to category 3. + $argument = array_shift($arguments); + $this->assertInstanceOf(\core_course_category::class, $argument); + $this->assertEquals($category3->id, $argument->id); + } + + public function test_pre_course_category_delete_hook() { + $category1 = \core_course_category::create(array('name' => 'Cat1')); + $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id)); + + $mockcategory2 = $this->get_mock_category($category2, 'pre_course_category_delete'); + $mockcategory2->delete_full(); + + // Verify passed arguments. + $arguments = mock_hooks::get_calling_arguments(); + $this->assertCount(1, $arguments); + + // Argument 1 is the category object. + $argument = array_shift($arguments); + $this->assertEquals($mockcategory2->get_db_record(), $argument); + } + + public function test_pre_course_category_delete_move_hook() { + $category1 = \core_course_category::create(array('name' => 'Cat1')); + $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id)); + $category3 = \core_course_category::create(array('name' => 'Cat3')); + + $mockcategory2 = $this->get_mock_category($category2, 'pre_course_category_delete_move'); + + // Add course to mocked clone of category2. + $course1 = $this->getDataGenerator()->create_course(array('category' => $mockcategory2->id)); + + $mockcategory2->delete_move($category3->id); + + // Verify passed arguments. + $arguments = mock_hooks::get_calling_arguments(); + $this->assertCount(2, $arguments); + + // Argument 1 is the same core_course_category instance. + $argument = array_shift($arguments); + $this->assertSame($mockcategory2, $argument); + + // Argument 2 is referring to category 3. + $argument = array_shift($arguments); + $this->assertInstanceOf(\core_course_category::class, $argument); + $this->assertEquals($category3->id, $argument->id); + } + + public function test_get_course_category_contents_hook() { + $category1 = \core_course_category::create(array('name' => 'Cat1')); + $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id)); + + $mockcategory2 = $this->get_mock_category($category2); + + // Define get_plugins_callback_function use in the mock, it is called twice for different callback in the form. + $mockcategory2->expects($this->exactly(2)) + ->method('get_plugins_callback_function') + ->withConsecutive( + [$this->equalTo('can_course_category_delete')], + [$this->equalTo('get_course_category_contents')] + ) + ->willReturn( + ['tool_unittest_can_course_category_delete'], + ['tool_unittest_get_course_category_contents'] + ); + + // Now configure fixture to return string for the callback. + $content = 'Bunch of test artefacts'; + mock_hooks::set_get_course_category_contents_return($content); + + $mform = new \core_course_deletecategory_form(null, $mockcategory2); + $this->expectOutputRegex("/
  • $content<\/li>/"); + $mform->display(); + + // Verify passed arguments. + $arguments = mock_hooks::get_calling_arguments(); + $this->assertCount(1, $arguments); + + // Argument 1 is the same core_course_category instance. + $argument = array_shift($arguments); + $this->assertSame($mockcategory2, $argument); + } +} diff --git a/course/tests/fixtures/mock_hooks.php b/course/tests/fixtures/mock_hooks.php new file mode 100644 index 00000000000..d5fc095ef8c --- /dev/null +++ b/course/tests/fixtures/mock_hooks.php @@ -0,0 +1,184 @@ +. + +/** + * Fixture for mocking callbacks used in \core_course_category + * + * @package core_course + * @category test + * @copyright 2020 Ruslan Kabalin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tests\core_course { + + /** + * Class mock_hooks + * + * @package core_course + * @category test + * @copyright 2020 Ruslan Kabalin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + class mock_hooks { + /** @var bool $cancoursecategorydelete */ + private static $cancoursecategorydelete = true; + + /** @var bool $cancoursecategorydeletemove */ + private static $cancoursecategorydeletemove = true; + + /** @var string $getcoursecategorycontents */ + private static $getcoursecategorycontents = ''; + + /** @var array $callingarguments */ + private static $callingarguments = []; + + /** + * Set calling arguments. + * + * This is supposed to be used in the callbacks to store arguments passed to callback. + * + * @param array $callingarguments + */ + public static function set_calling_arguments($callingarguments) { + self::$callingarguments = $callingarguments; + } + + /** + * Get calling arguments. + * + * This is supposed to be used in the test to verify arguments passed to callback. + * This method also reset stored calling arguments. + * + * @return array $callingarguments + */ + public static function get_calling_arguments() { + $callingarguments = self::$callingarguments; + self::$callingarguments = []; + return $callingarguments; + } + + /** + * Get can_course_category_delete callback return. + * + * @return bool + */ + public static function get_can_course_category_delete_return() : bool { + return self::$cancoursecategorydelete; + } + + /** + * Sets can_course_category_delete callback return. + * + * @param bool $return + */ + public static function set_can_course_category_delete_return(bool $return) { + self::$cancoursecategorydelete = $return; + } + + /** + * Get can_course_category_delete_move callback return. + * + * @return bool + */ + public static function get_can_course_category_delete_move_return() : bool { + return self::$cancoursecategorydeletemove; + } + + /** + * Sets can_course_category_delete_move callback return. + * + * @param bool $return + */ + public static function set_can_course_category_delete_move_return(bool $return) { + self::$cancoursecategorydeletemove = $return; + } + + /** + * Get get_course_category_contents callback return. + * + * @return string + */ + public static function get_get_course_category_contents_return() : string { + return self::$getcoursecategorycontents; + } + + /** + * Sets get_course_category_contents callback return. + * + * @param string $return + */ + public static function set_get_course_category_contents_return(string $return) { + self::$getcoursecategorycontents = $return; + } + } +} + +namespace { + + /** + * Test pre_course_category_delete callback. + * + * @param object $category + */ + function tool_unittest_pre_course_category_delete(object $category) { + \tests\core_course\mock_hooks::set_calling_arguments(func_get_args()); + } + + /** + * Test pre_course_category_delete_move callback. + * + * @param core_course_category $category + * @param core_course_category $newcategory + */ + function tool_unittest_pre_course_category_delete_move(core_course_category $category, core_course_category $newcategory) { + \tests\core_course\mock_hooks::set_calling_arguments(func_get_args()); + } + + /** + * Test can_course_category_delete callback. + * + * @param core_course_category $category + * @return bool + */ + function tool_unittest_can_course_category_delete(core_course_category $category) { + \tests\core_course\mock_hooks::set_calling_arguments(func_get_args()); + return \tests\core_course\mock_hooks::get_can_course_category_delete_return(); + } + + /** + * Test can_course_category_delete_move callback. + * + * @param core_course_category $category + * @param core_course_category $newcategory + * @return bool + */ + function tool_unittest_can_course_category_delete_move(core_course_category $category, core_course_category $newcategory) { + \tests\core_course\mock_hooks::set_calling_arguments(func_get_args()); + return \tests\core_course\mock_hooks::get_can_course_category_delete_move_return(); + } + + /** + * Test get_course_category_contents callback. + * + * @param core_course_category $category + * @return string + */ + function tool_unittest_get_course_category_contents(core_course_category $category) { + \tests\core_course\mock_hooks::set_calling_arguments(func_get_args()); + return \tests\core_course\mock_hooks::get_get_course_category_contents_return(); + } +} \ No newline at end of file