From d8a1f4263a3f18822ab53aeaded1e684aed94213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20S=CC=8Ckoda?= Date: Sun, 16 Jun 2013 15:33:47 +0200 Subject: [PATCH] MDL-39846 implement new event dispatching and base event class --- lang/en/cache.php | 1 + lib/classes/event/base.php | 466 +++++++++++++++++++++++ lib/classes/event/manager.php | 325 ++++++++++++++++ lib/db/caches.php | 9 + lib/db/events.php | 29 +- lib/dml/moodle_database.php | 5 + lib/phpunit/classes/util.php | 1 + lib/tests/event_test.php | 527 ++++++++++++++++++++++++++ lib/tests/eventslib_test.php | 2 +- lib/tests/fixtures/event_fixtures.php | 140 +++++++ lib/tests/fixtures/events.php | 10 +- 11 files changed, 1499 insertions(+), 16 deletions(-) create mode 100644 lib/classes/event/base.php create mode 100644 lib/classes/event/manager.php create mode 100644 lib/tests/event_test.php create mode 100644 lib/tests/fixtures/event_fixtures.php diff --git a/lang/en/cache.php b/lang/en/cache.php index 72e913d37e8..be7bb404c82 100644 --- a/lang/en/cache.php +++ b/lang/en/cache.php @@ -47,6 +47,7 @@ $string['cachedef_eventinvalidation'] = 'Event invalidation'; $string['cachedef_groupdata'] = 'Course group information'; $string['cachedef_htmlpurifier'] = 'HTML Purifier - cleaned content'; $string['cachedef_locking'] = 'Locking'; +$string['cachedef_observers'] = 'Event observers'; $string['cachedef_plugininfo_base'] = 'Plugin info - base'; $string['cachedef_plugininfo_block'] = 'Plugin info - blocks'; $string['cachedef_plugininfo_filter'] = 'Plugin info - filters'; diff --git a/lib/classes/event/base.php b/lib/classes/event/base.php new file mode 100644 index 00000000000..6e1f70db0fe --- /dev/null +++ b/lib/classes/event/base.php @@ -0,0 +1,466 @@ +. + +namespace core\event; + +/** + * Base event class. + * + * @package core + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +/** + * All other event classes must extend this class. + * + * @package core + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @property-read string $eventname Name of the event (=== class name with leading \) + * @property-read string $component Full frankenstyle component name + * @property-read string $action what happened + * @property-read string $object what/who was object of the action (usually similar to database table name) + * @property-read int $objectid optional id of the object + * @property-read string $crud letter indicating event type + * @property-read int $level log level + * @property-read int $contextid + * @property-read int $contextlevel + * @property-read int $contextinstanceid + * @property-read int $userid who did this? + * @property-read int $courseid + * @property-read int $relateduserid + * @property-read mixed $extra array or scalar, can not contain objects + * @property-read int $realuserid who really did this? + * @property-read string $origin + * @property-read int $timecreated + */ +abstract class base { + /** @var array event data */ + protected $data; + + /** @var \context of this event */ + protected $context; + + /** @var bool indicates if event was already triggered */ + private $triggered; + + /** @var bool indicates if event was restored from storage */ + private $restored; + + /** @var array list of event properties */ + private static $fields = array( + 'eventname', 'component', 'action', 'object', 'objectid', 'crud', 'level', 'contextid', + 'contextlevel', 'contextinstanceid', 'userid', 'courseid', 'relateduserid', 'extra', + 'realuserid', 'origin', 'timecreated'); + + /** @var array simple record cache */ + protected $cachedrecords = array(); + + /** + * Private constructor, use create() or restore() methods instead. + */ + private final function __construct() { + $this->data = array_fill_keys(self::$fields, null); + } + + /** + * Create new event. + * + * The optional data keys as: + * 1/ objectid - the id of the object specified in class name + * 2/ context - the context of this event + * 3/ extra - the extra data describing the event, can not contain objects + * 4/ relateduserid - the id of user which is somehow related to this event + * + * @param array $data + * @return \core\event\base returns instance of new event + * + * @throws \coding_exception + */ + public static final function create(array $data = null) { + global $PAGE, $USER; + + $data = (array)$data; + + /** @var \core\event\base $event */ + $event = new static(); + $event->triggered = false; + $event->restored = false; + + $classname = get_class($event); + $parts = explode('\\', $classname); + if (count($parts) !== 3 or $parts[1] !== 'event') { + throw new \coding_exception("Invalid event class name '$classname', it must be defined in component\\event\\ namespace"); + } + $event->data['eventname'] = '\\'.$classname; + $event->data['component'] = $parts[0]; + + $pos = strrpos($parts[2], '_'); + if ($pos === false) { + throw new \coding_exception("Invalid event class name '$classname', there must be at least one underscore separating object and action words"); + } + $event->data['object'] = substr($parts[2], 0, $pos); + $event->data['action'] = substr($parts[2], $pos+1); + + // Do not let developers to something crazy. + if (debugging('', DEBUG_DEVELOPER)) { + $keys = array('eventname', 'component', 'action', 'object', 'realuserid', 'origin', 'timecreated'); + foreach ($keys as $key) { + if (array_key_exists($key, $data)) { + debugging("Data key '$key' is no allowed in event \\core\\event\\base::create() method, it is set automatically.", DEBUG_DEVELOPER); + } + } + $keys = array('crud', 'level'); + foreach ($keys as $key) { + if (array_key_exists($key, $data)) { + debugging("Data key '$key' is no allowed in event \\core\\event\\base::create() method, you need to set it in init method.", DEBUG_DEVELOPER); + } + } + } + unset($data['eventname']); + unset($data['component']); + unset($data['action']); + unset($data['object']); + unset($data['crud']); + unset($data['level']); + unset($data['realuserid']); + unset($data['origin']); + unset($data['timecreated']); + + // Set optional data. + $event->data['objectid'] = isset($data['objectid']) ? $data['objectid'] : null; + $event->data['courseid'] = isset($data['courseid']) ? $data['courseid'] : null; + $event->data['userid'] = isset($data['userid']) ? $data['userid'] : $USER->id; + $event->data['extra'] = isset($data['extra']) ? $data['extra'] : null; + $event->data['relateduserid'] = isset($data['relateduserid']) ? $data['relateduserid'] : null; + + $event->context = null; + if (isset($data['context'])) { + $event->context = $data['context']; + } else if (isset($data['contextid'])) { + $event->context = \context::instance_by_id($data['contextid']); + } else if ($event->data['courseid']) { + $event->context = \context_course::instance($event->data['courseid']); + } else if (isset($PAGE)) { + $event->context = $PAGE->context; + } + if (!$event->context) { + $event->context = \context_system::instance(); + } + unset($data['context']); + $event->data['contextid'] = $event->context->id; + $event->data['contextlevel'] = $event->context->contextlevel; + $event->data['contextinstanceid'] = $event->context->instanceid; + + if (!isset($event->data['courseid'])) { + if ($coursecontext = $event->context->get_course_context(false)) { + $event->data['courseid'] = $coursecontext->id; + } else { + $event->data['courseid'] = 0; + } + } + + if (!array_key_exists('relateduserid', $data) and $event->context->contextlevel == CONTEXT_USER) { + $event->data['relateduserid'] = $event->context->instanceid; + } + + if (CLI_SCRIPT) { + $event->data['origin'] = 'cli'; + } else if (AJAX_SCRIPT) { + $event->data['origin'] = 'ajax:'.getremoteaddr(); + } else { + $event->data['origin'] = 'web:'.getremoteaddr(); + // TODO: detect web services somehow, for now it is logged separately. + } + + if (debugging('', DEBUG_DEVELOPER)) { + foreach (array_keys($data) as $key) { + if (!in_array($key, self::$fields)) { + debugging("Unsupported event data field '$key' detected."); + } + } + } + + $event->init(); + + return $event; + } + + /** + * Override in subclass. + * + * Set all required data properties: + * 1/ crud + * 2/ level + * + * @return void + */ + protected abstract function init(); + + /** + * Restore event from existing historic data. + * + * @param array $data + * @return bool|\core\event\base + */ + public static final function restore(array $data = null) { + $classname = $data['eventname']; + $component = $data['component']; + $action = $data['action']; + $object = $data['object']; + + // Security: make 100% sure this really is an event class. + if ($classname !== "\\{$component}\\event\\{$object}_{$action}") { + return false; + } + + if (!class_exists($classname)) { + return false; + } + $event = new $classname(); + if (!($event instanceof \core\event\base)) { + return false; + } + + $event->triggered = true; + $event->restored = true; + + foreach (self::$fields as $key) { + if (array_key_exists($key, $data)) { + $event->data[$key] = $data[$key]; + } else { + debugging("Event restore data must contain key $key"); + $event->data[$key] = null; + } + } + + return $event; + } + + /** + * Returns localised event name. + * + * Note: override in child class. + * + * @return string + */ + public function get_name() { + return $this->data['eventname']; + } + + /** + * Returns event context. + * @return \context + */ + public function get_context() { + if (isset($this->context)) { + return $this->context; + } + $this->context = \context::instance_by_id($this->data['contextid'], false); + return $this->context; + } + + /** + * Returns relevant URL, override in subclasses. + */ + public function get_url() { + return null; + } + + /** + * Return standardised event data as array. + * + * Useful especially for logging of events. + * + * @return array + */ + public function get_data() { + return $this->data; + } + + /** + * Does this event replace legacy event? + * + * @return null|string legacy event name + */ + public function get_legacy_eventname() { + return null; + } + + /** + * Legacy event data if get_legacy_eventname() is not empty. + * + * @return mixed + */ + public function get_legacy_eventdata() { + return null; + } + + /** + * Doest this event replace add_to_log() statement? + * + * @return null|array of parameters to be passed to legacy add_to_log() function. + */ + public function get_legacy_logdata() { + return null; + } + + /** + * Validate all properties right before triggering the event. + * + * This throws coding exceptions for fatal problems and debugging for minor problems. + * + * @throws \coding_exception + */ + protected final function validate_before_trigger() { + if (empty($this->data['crud'])) { + throw new \coding_exception('crud must be specified in init() method of each method'); + } + if (empty($this->data['level'])) { + throw new \coding_exception('level must be specified in init() method of each method'); + } + + if (debugging('', DEBUG_DEVELOPER)) { + if (!in_array($this->data['crud'], array('c', 'r', 'u', 'd'), true)) { + debugging("Invalid event crud value specified.", DEBUG_DEVELOPER); + } + // Ideally these should be coding exceptions, but we need to skip these for performance reasons + // on production servers. + if (self::$fields !== array_keys($this->data)) { + debugging('Number of event data fields must not be changed in event classes', DEBUG_DEVELOPER); + } + + $encoded = json_encode($this->data['extra']); + if ($encoded === false or $this->data['extra'] !== json_decode($encoded, true)) { + debugging('Extra event data must be compatible with json encoding', DEBUG_DEVELOPER); + } + } + } + + /** + * Trigger event. + */ + public final function trigger() { + global $CFG; + + if ($this->restored) { + throw new \coding_exception('Can not trigger restored event'); + } + if ($this->triggered) { + throw new \coding_exception('Can not trigger event twice'); + } + + $this->triggered = true; + + $this->validate_before_trigger(); + + if (!empty($CFG->loglifetime)) { + if ($data = $this->get_legacy_logdata()) { + call_user_func_array('add_to_log', $data); + } + } + + \core\event\manager::dispatch($this); + + if ($legacyeventname = $this->get_legacy_eventname()) { + events_trigger($legacyeventname, $this->get_legacy_eventdata()); + } + } + + /** + * Was this event already triggered. + * + * Note: restored events are considered to be triggered too. + * + * @return bool + */ + public function is_triggered() { + return $this->triggered; + } + + /** + * Was this evetn restored? + * + * @return bool + */ + public function is_restored() { + return $this->restored; + } + + /** + * Add cached data that will be most probably used in event observers. + * + * This is used to improve performance, but it is required for data + * thar was just deleted. + * + * @param string $tablename + * @param \stdClass $record + */ + public function add_cached_record($tablename, $record) { + global $DB; + + // NOTE: this might use some kind of MUC cache, + // hopefully we will not run out of memory here... + if (debugging('', DEBUG_DEVELOPER)) { + if (!$DB->get_manager()->table_exists($tablename)) { + debugging("Invalid table name '$tablename' specified, database table does not exist."); + } + } + $this->cachedrecords[$tablename][$record->id] = $record; + } + + /** + * Returns cached record or fetches data from database if not cached. + * + * @param string $tablename + * @param int $id + * @return \stdClass + */ + public function get_cached_record($tablename, $id) { + global $DB; + + if (isset($this->cachedrecords[$tablename][$id])) { + return $this->cachedrecords[$tablename][$id]; + } + + $record = $DB->get_record($tablename, array('id'=>$id)); + $this->cachedrecords[$tablename][$id] = $record; + + return $record; + } + + /** + * Magic getter for read only access. + * + * Note: we must not allow modification of data from outside, + * after trigger() the data MUST NOT CHANGE!!! + * + * @param string $name + * @return mixed + * + * @throws \coding_exception + */ + public function __get($name) { + if (array_key_exists($name, $this->data)) { + return $this->data[$name]; + } + + throw new \coding_exception("Accessing non-existent property $name from event class"); + } +} diff --git a/lib/classes/event/manager.php b/lib/classes/event/manager.php new file mode 100644 index 00000000000..dab87293a4e --- /dev/null +++ b/lib/classes/event/manager.php @@ -0,0 +1,325 @@ +. + +namespace core\event; + +/** + * New event manager class. + * + * @package core + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Class used for event dispatching. + * + * Note: Do NOT use directly in your code, it is intended to be used from + * base event class only. + */ +class manager { + /** @var array buffer of event for dispatching */ + protected static $buffer = array(); + + /** @var array buffer for events that were not sent to external observers when DB transaction in progress */ + protected static $extbuffer = array(); + + /** @var bool evert dispatching already in progress - prevents nesting */ + protected static $dispatching = false; + + /** @var array cache of all observers */ + protected static $allobservers = null; + + /** @var bool should we reload observers after the test? */ + protected static $reloadaftertest = false; + + /** + * Trigger new event. + * + * @internal to be used only from \core\event\base::trigger() method. + * + * @param \core\event\base $event + */ + public static function dispatch(\core\event\base $event) { + if (during_initial_install()) { + return; + } + + self::$buffer[] = $event; + + if (self::$dispatching) { + return; + } + + self::$dispatching = true; + self::process_buffers(); + self::$dispatching = false; + } + + /** + * Notification from DML layer. + * @internal to be used from DML layer only. + */ + public static function database_transaction_commited() { + if (self::$dispatching or empty(self::$extbuffer)) { + return; + } + + self::$dispatching = true; + self::process_buffers(); + self::$dispatching = false; + } + + /** + * Notification from DML layer. + * @internal to be used from DML layer only. + */ + public static function database_transaction_rolledback() { + self::$extbuffer = array(); + } + + protected static function process_buffers() { + global $DB, $CFG; + + while (self::$buffer or self::$extbuffer) { + + $fromextbuffer = false; + $addedtoextbuffer = false; + + if (self::$extbuffer and !$DB->is_transaction_started()) { + $fromextbuffer = true; + $event = reset(self::$extbuffer); + unset(self::$extbuffer[key(self::$extbuffer)]); + + } else if (self::$buffer) { + $event = reset(self::$buffer); + unset(self::$buffer[key(self::$buffer)]); + + } else { + return; + } + + $observers = self::get_event_observers('\\'.get_class($event)); + + foreach ($observers as $observer) { + if ($observer->internal) { + if ($fromextbuffer) { + // Do not send buffered external events to internal handlers, + // they processed them already. + continue; + } + } else { + if ($DB->is_transaction_started()) { + if ($fromextbuffer) { + // Weird! + continue; + } + // Do not notify external observers while in DB transaction. + if (!$addedtoextbuffer) { + self::$extbuffer[] = $event; + $addedtoextbuffer = true; + } + continue; + } + } + + if (isset($observer->includefile) and file_exists($observer->includefile)) { + include_once($observer->includefile); + } + if (is_callable($observer->callable)) { + try { + call_user_func($observer->callable, $event); + } catch (\Exception $e) { + // Observers are notified before installation and upgrade, this may throw errors. + if (empty($CFG->upgraderunning)) { + // Ignore errors during upgrade, otherwise warn developers. + debugging("Exception encountered in event observer '$observer->callable': ".$e->getMessage(), DEBUG_DEVELOPER, $e->getTrace()); + } + } + } else { + debugging("Can not execute event observer '$observer->callable'"); + } + } + + // TODO: Invent some infinite loop protection in case events cross-trigger one another. + } + } + + protected static function get_event_observers($classname) { + self::init_all_observers(); + + if (isset(self::$allobservers[$classname])) { + return self::$allobservers[$classname]; + } + + if (isset(self::$allobservers['*'])) { + return self::$allobservers['*']; + } + + return array(); + } + + protected static function init_all_observers() { + global $CFG; + + if (is_array(self::$allobservers)) { + return; + } + + if (!PHPUNIT_TEST and !during_initial_install()) { + $cache = \cache::make('core', 'observers'); + $cached = $cache->get('all'); + $dirroot = $cache->get('dirroot'); + if ($dirroot === $CFG->dirroot and is_array($cached)) { + self::$allobservers = $cached; + return; + } + } + + self::$allobservers = array(); + + $plugintypes = \core_component::get_plugin_types(); + $systemdone = false; + foreach ($plugintypes as $plugintype => $ignored) { + $plugins = \core_component::get_plugin_list($plugintype); + if (!$systemdone) { + $plugins[] = "$CFG->dirroot/lib"; + $systemdone = true; + } + + foreach ($plugins as $fulldir) { + if (!file_exists("$fulldir/db/events.php")) { + continue; + } + $observers = null; + include("$fulldir/db/events.php"); + if (!is_array($observers)) { + continue; + } + self::add_observers($observers, "$fulldir/db/events.php"); + } + } + + self::order_all_observers(); + + if (!PHPUNIT_TEST and !during_initial_install()) { + $cache->set('all', self::$allobservers); + $cache->set('dirroot', $CFG->dirroot); + } + } + + protected static function add_observers(array $observers, $file) { + global $CFG; + + foreach ($observers as $observer) { + if (empty($observer['eventname']) or !is_string($observer['eventname'])) { + debugging("Invalid 'eventname' detected in $file observer definition", DEBUG_DEVELOPER); + continue; + } + if ($observer['eventname'] !== '*' and strpos($observer['eventname'], '\\') !== 0) { + $observer['eventname'] = '\\'.$observer['eventname']; + } + if (empty($observer['callable'])) { + debugging("Invalid 'callable' detected in $file observer definition", DEBUG_DEVELOPER); + continue; + } + $o = new \stdClass(); + $o->callable = $observer['callable']; + if (!isset($observer['priority'])) { + $o->priority = 0; + } else { + $o->priority = (int)$observer['priority']; + } + if (!isset($observer['internal'])) { + $o->internal = true; + } else { + $o->internal = (bool)$observer['internal']; + } + if (empty($observer['includefile'])) { + $o->includefile = null; + } else { + if ($CFG->admin !== 'admin' and strpos($observer['includefile'], '/admin/') === 0) { + $observer['includefile'] = preg_replace('|^/admin/|', '/'.$CFG->admin.'/', $observer['includefile']); + } + if (!file_exists($observer['includefile'])) { + debugging("Invalid 'includefile' detected in $file observer definition", DEBUG_DEVELOPER); + continue; + } + $o->includefile = $observer['includefile']; + } + self::$allobservers[$observer['eventname']][] = $o; + } + } + + protected static function order_all_observers() { + $catchall = array(); + if (isset(self::$allobservers['*'])) { + $catchall = self::$allobservers['*']; + unset(self::$allobservers['*']); // Move it to the end. + \collatorlib::asort_objects_by_property($catchall, 'priority', \collatorlib::SORT_NUMERIC); + $catchall = array_reverse($catchall); + self::$allobservers['*'] = $catchall; + } + foreach (self::$allobservers as $classname => $observers) { + if ($classname === '*') { + continue; + } + if ($catchall) { + $observers = array_merge($observers, $catchall); + } + + \collatorlib::asort_objects_by_property($observers, 'priority', \collatorlib::SORT_NUMERIC); + self::$allobservers[$classname] = array_reverse($observers); + } + } + + /** + * Replace all standard observers. + * @param array $observers + * @return array + */ + public static function phpunit_replace_observers(array $observers) { + if (!PHPUNIT_TEST) { + throw new \coding_exception('Cannot override event observers outside of phpunit tests!'); + } + + self::phpunit_reset(); + self::$allobservers = array(); + self::$reloadaftertest = true; + + self::add_observers($observers, 'phpunit'); + self::order_all_observers(); + + return self::$allobservers; + } + + /** + * Reset everything if necessary. + * @private + */ + public static function phpunit_reset() { + if (!PHPUNIT_TEST) { + throw new \coding_exception('Cannot reset event manager outside of phpunit tests!'); + } + self::$buffer = array(); + self::$extbuffer = array(); + self::$dispatching = false; + if (!self::$reloadaftertest) { + self::$allobservers = null; + } + self::$reloadaftertest = false; + } +} diff --git a/lib/db/caches.php b/lib/db/caches.php index 980837e40a9..ffb2877d495 100644 --- a/lib/db/caches.php +++ b/lib/db/caches.php @@ -121,6 +121,15 @@ $definitions = array( 'mode' => cache_store::MODE_APPLICATION, ), + // Cache for the list of event observers. + 'observers' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'persistent' => true, + 'persistentmaxsize' => 2, + ), + // Cache used by the {@link plugininfo_base} class. 'plugininfo_base' => array( 'mode' => cache_store::MODE_APPLICATION, diff --git a/lib/db/events.php b/lib/db/events.php index f6ebc9e61fd..1e354fc19c8 100644 --- a/lib/db/events.php +++ b/lib/db/events.php @@ -33,23 +33,10 @@ defined('MOODLE_INTERNAL') || die(); -/* List of handlers */ +/* List of legacy event handlers */ $handlers = array( -/* - * portfolio queued event - for non interactive file transfers - * NOTE: this is a HACK, please do not add any more things like this here - * (it is just abusing cron to do very time consuming things which is wrong any way) - * - * TODO: this has to be moved into separate queueing framework.... - */ - 'portfolio_send' => array ( - 'handlerfile' => '/lib/portfolio.php', - 'handlerfunction' => 'portfolio_handle_event', // argument to call_user_func(), could be an array - 'schedule' => 'cron', - 'internal' => 0, - ), 'course_completed' => array ( 'handlerfile' => '/lib/badgeslib.php', 'handlerfunction' => 'badges_award_handle_course_criteria_review', @@ -69,6 +56,20 @@ $handlers = array( 'internal' => 1, ), +/* + * portfolio queued event - for non interactive file transfers + * NOTE: this is a HACK, please do not add any more things like this here + * (it is just abusing cron to do very time consuming things which is wrong any way) + * + * TODO: this has to be moved into separate queueing framework.... + */ + 'portfolio_send' => array ( + 'handlerfile' => '/lib/portfolio.php', + 'handlerfunction' => 'portfolio_handle_event', // argument to call_user_func(), could be an array + 'schedule' => 'cron', + 'internal' => 0, + ), + /* no more here please, core should not consume any events!!!!!!! */ ); diff --git a/lib/dml/moodle_database.php b/lib/dml/moodle_database.php index 2a6676c84e7..5835c88a6f1 100644 --- a/lib/dml/moodle_database.php +++ b/lib/dml/moodle_database.php @@ -2210,6 +2210,10 @@ abstract class moodle_database { $this->commit_transaction(); } array_pop($this->transactions); + + if (empty($this->transactions)) { + \core\event\manager::database_transaction_commited(); + } } /** @@ -2255,6 +2259,7 @@ abstract class moodle_database { if (empty($this->transactions)) { // finally top most level rolled back $this->force_rollback = false; + \core\event\manager::database_transaction_rolledback(); } throw $e; } diff --git a/lib/phpunit/classes/util.php b/lib/phpunit/classes/util.php index b04940904b5..e5a62ba7db1 100644 --- a/lib/phpunit/classes/util.php +++ b/lib/phpunit/classes/util.php @@ -182,6 +182,7 @@ class phpunit_util extends testing_util { session_set_user($user); // reset all static caches + \core\event\manager::phpunit_reset(); accesslib_clear_all_caches(true); get_string_manager()->reset_caches(true); reset_text_filters_cache(true); diff --git a/lib/tests/event_test.php b/lib/tests/event_test.php new file mode 100644 index 00000000000..f99bc9fb239 --- /dev/null +++ b/lib/tests/event_test.php @@ -0,0 +1,527 @@ +. + +/** + * Tests for event manager, base event and observers. + * + * @package core + * @category phpunit + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__.'/fixtures/event_fixtures.php'); + +class core_event_testcase extends advanced_testcase { + + public function test_observers_parsing() { + + $observers = array( + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::observe_one', + 'includefile' => 'lib/tests/fixtures/event_fixtures.php', + ), + array( + 'eventname' => '*', + 'callable' => '\core_tests\event\unittest_observer::observe_all', + 'includefile' => null, + 'internal' => 1, + 'priority' => 9999, + ), + array( + 'eventname' => '\core\event\unknown_executed', + 'callable' => '\core_tests\event\unittest_observer::broken_observer', + 'priority' => 100, + ), + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::external_observer', + 'priority' => 200, + 'internal' => 0, + ), + ); + + $result = \core\event\manager::phpunit_replace_observers($observers); + + $this->assertCount(3, $result); + end($result); + $this->assertSame('*', key($result)); + + $expected = array(); + $observer = new stdClass(); + $observer->callable = '\core_tests\event\unittest_observer::observe_all'; + $observer->priority = 9999; + $observer->internal = true; + $observer->includefile = null; + $expected[0] = $observer; + $observer = new stdClass(); + $observer->callable = '\core_tests\event\unittest_observer::external_observer'; + $observer->priority = 200; + $observer->internal = false; + $observer->includefile = null; + $expected[1] = $observer; + $observer = new stdClass(); + $observer->callable = '\core_tests\event\unittest_observer::observe_one'; + $observer->priority = 0; + $observer->internal = true; + $observer->includefile = 'lib/tests/fixtures/event_fixtures.php'; + $expected[2] = $observer; + + $this->assertEquals($expected, $result['\core_tests\event\unittest_executed']); + + $expected = array(); + $observer = new stdClass(); + $observer->callable = '\core_tests\event\unittest_observer::observe_all'; + $observer->priority = 9999; + $observer->internal = true; + $observer->includefile = null; + $expected[0] = $observer; + $observer = new stdClass(); + $observer->callable = '\core_tests\event\unittest_observer::broken_observer'; + $observer->priority = 100; + $observer->internal = true; + $observer->includefile = null; + $expected[1] = $observer; + + $this->assertEquals($expected, $result['\core\event\unknown_executed']); + + $expected = array(); + $observer = new stdClass(); + $observer->callable = '\core_tests\event\unittest_observer::observe_all'; + $observer->priority = 9999; + $observer->internal = true; + $observer->includefile = null; + $expected[0] = $observer; + + $this->assertEquals($expected, $result['*']); + + + // Now test broken stuff... + + $observers = array( + array( + 'eventname' => 'core_tests\event\unittest_executed', // Fix leading backslash. + 'callable' => '\core_tests\event\unittest_observer::observe_one', + 'includefile' => 'lib/tests/fixtures/event_fixtures.php', + 'internal' => 1, // Cast to bool. + ), + ); + $result = \core\event\manager::phpunit_replace_observers($observers); + $this->assertCount(1, $result); + $expected = array(); + $observer = new stdClass(); + $observer->callable = '\core_tests\event\unittest_observer::observe_one'; + $observer->priority = 0; + $observer->internal = true; + $observer->includefile = 'lib/tests/fixtures/event_fixtures.php'; + $expected[0] = $observer; + $this->assertEquals($expected, $result['\core_tests\event\unittest_executed']); + + $observers = array( + array( + // Missing eventclass. + 'callable' => '\core_tests\event\unittest_observer::observe_one', + 'includefile' => 'lib/tests/fixtures/event_fixtures.php', + ), + ); + $result = \core\event\manager::phpunit_replace_observers($observers); + $this->assertCount(0, $result); + $this->assertDebuggingCalled(); + + $observers = array( + array( + 'eventname' => '', // Empty eventclass. + 'callable' => '\core_tests\event\unittest_observer::observe_one', + 'includefile' => 'lib/tests/fixtures/event_fixtures.php', + ), + ); + $result = \core\event\manager::phpunit_replace_observers($observers); + $this->assertCount(0, $result); + $this->assertDebuggingCalled(); + + $observers = array( + array( + 'eventname' => '\core_tests\event\unittest_executed', + // Missing callable. + 'includefile' => 'lib/tests/fixtures/event_fixtures.php', + ), + ); + $result = \core\event\manager::phpunit_replace_observers($observers); + $this->assertCount(0, $result); + $this->assertDebuggingCalled(); + + $observers = array( + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '', // empty callable + 'includefile' => 'lib/tests/fixtures/event_fixtures.php', + ), + ); + $result = \core\event\manager::phpunit_replace_observers($observers); + $this->assertCount(0, $result); + $this->assertDebuggingCalled(); + + $observers = array( + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::observe_one', + 'includefile' => 'lib/tests/fixtures/event_fixtures.php_xxx', // Missing file. + ), + ); + $result = \core\event\manager::phpunit_replace_observers($observers); + $this->assertCount(0, $result); + $this->assertDebuggingCalled(); + } + + public function test_normal_dispatching() { + $observers = array( + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::observe_one', + ), + array( + 'eventname' => '*', + 'callable' => '\core_tests\event\unittest_observer::observe_all', + 'includefile' => null, + 'internal' => 1, + 'priority' => 9999, + ), + ); + + \core\event\manager::phpunit_replace_observers($observers); + \core_tests\event\unittest_observer::reset(); + + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>1, 'xx'=>10))); + $event1->nest = 1; + $this->assertFalse($event1->is_triggered()); + $this->assertFalse($event1->is_restored()); + $event1->trigger(); + $this->assertTrue($event1->is_triggered()); + $this->assertFalse($event1->is_restored()); + + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>2, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>2, 'xx'=>10))); + $event1->trigger(); + + $this->assertSame( + array('observe_all-nesting-1', 'observe_one-1', 'observe_all-3', 'observe_one-3', 'observe_all-2', 'observe_one-2'), + \core_tests\event\unittest_observer::$info); + } + + public function test_ignore_exceptions() { + $observers = array( + + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::observe_one', + ), + + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::broken_observer', + 'priority' => 100, + ), + ); + + \core\event\manager::phpunit_replace_observers($observers); + \core_tests\event\unittest_observer::reset(); + + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>1, 'xx'=>10))); + $event1->trigger(); + $this->assertDebuggingCalled(); + + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>2, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>2, 'xx'=>10))); + $event1->trigger(); + $this->assertDebuggingCalled(); + + $this->assertSame( + array('broken_observer-1', 'observe_one-1', 'broken_observer-2', 'observe_one-2'), + \core_tests\event\unittest_observer::$info); + } + + public function test_external_buffer() { + global $DB; + + $this->preventResetByRollback(); + + $observers = array( + + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::observe_one', + ), + + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::external_observer', + 'priority' => 200, + 'internal' => 0, + ), + ); + + \core\event\manager::phpunit_replace_observers($observers); + \core_tests\event\unittest_observer::reset(); + + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>1, 'xx'=>10))); + $event1->trigger(); + $event2 = \core_tests\event\unittest_executed::create(array('courseid'=>2, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>2, 'xx'=>10))); + $event2->trigger(); + + $this->assertSame( + array('external_observer-1', 'observe_one-1', 'external_observer-2', 'observe_one-2'), + \core_tests\event\unittest_observer::$info); + + \core\event\manager::phpunit_replace_observers($observers); + \core_tests\event\unittest_observer::reset(); + + $this->assertSame(array(), \core_tests\event\unittest_observer::$info); + + $trans = $DB->start_delegated_transaction(); + + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>1, 'xx'=>10))); + $event1->trigger(); + $event2 = \core_tests\event\unittest_executed::create(array('courseid'=>2, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>2, 'xx'=>10))); + $event2->trigger(); + + $this->assertSame( + array('observe_one-1', 'observe_one-2'), + \core_tests\event\unittest_observer::$info); + + $trans->allow_commit(); + + $this->assertSame( + array('observe_one-1', 'observe_one-2', 'external_observer-1', 'external_observer-2'), + \core_tests\event\unittest_observer::$info); + + \core\event\manager::phpunit_replace_observers($observers); + \core_tests\event\unittest_observer::reset(); + + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>1, 'xx'=>10))); + $event1->trigger(); + $trans = $DB->start_delegated_transaction(); + $event2 = \core_tests\event\unittest_executed::create(array('courseid'=>2, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>2, 'xx'=>10))); + $event2->trigger(); + try { + $trans->rollback(new \moodle_exception('xxx')); + $this->fail('Expecting exception'); + } catch (\moodle_exception $e) { + } + + $this->assertSame( + array('external_observer-1', 'observe_one-1', 'observe_one-2'), + \core_tests\event\unittest_observer::$info); + } + + public function test_legacy() { + global $DB; + + $this->resetAfterTest(true); + + $observers = array( + array( + 'eventname' => '\core_tests\event\unittest_executed', + 'callable' => '\core_tests\event\unittest_observer::observe_one', + ), + array( + 'eventname' => '*', + 'callable' => '\core_tests\event\unittest_observer::observe_all', + 'includefile' => null, + 'internal' => 1, + 'priority' => 9999, + ), + ); + + $DB->delete_records('log', array()); + events_update_definition('unittest'); + $DB->delete_records_select('events_handlers', "component <> 'unittest'"); + events_get_handlers('reset'); + $this->assertEquals(3, $DB->count_records('events_handlers')); + set_config('loglifetime', 60*60*24*5); + + \core\event\manager::phpunit_replace_observers($observers); + \core_tests\event\unittest_observer::reset(); + + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>5, 'xx'=>10))); + $event1->trigger(); + + $event2 = \core_tests\event\unittest_executed::create(array('courseid'=>2, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>6, 'xx'=>11))); + $event2->nest = true; + $event2->trigger(); + + + $this->assertSame( + array('observe_all-1', 'observe_one-1', 'legacy_handler-1', 'observe_all-nesting-2', 'legacy_handler-3', 'observe_one-2', 'observe_all-3', 'observe_one-3', 'legacy_handler-2'), + \core_tests\event\unittest_observer::$info); + + $this->assertSame($event1, \core_tests\event\unittest_observer::$event[0]); + $this->assertSame($event1, \core_tests\event\unittest_observer::$event[1]); + $this->assertSame(array(1, 5), \core_tests\event\unittest_observer::$event[2]); + + + $logs = $DB->get_records('log', array(), 'id ASC'); + $this->assertCount(3, $logs); + + $log = array_shift($logs); + $this->assertEquals(1, $log->course); + $this->assertSame('core_unittest', $log->module); + $this->assertSame('view', $log->action); + + $log = array_shift($logs); + $this->assertEquals(2, $log->course); + $this->assertSame('core_unittest', $log->module); + $this->assertSame('view', $log->action); + + $log = array_shift($logs); + $this->assertEquals(3, $log->course); + $this->assertSame('core_unittest', $log->module); + $this->assertSame('view', $log->action); + } + + public function test_restore_event() { + $event1 = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>1, 'xx'=>10))); + $data1 = $event1->get_data(); + + $event2 = \core\event\base::restore($data1); + $data2 = $event2->get_data(); + + $this->assertTrue($event2->is_triggered()); + $this->assertTrue($event2->is_restored()); + $this->assertEquals($data1, $data2); + $this->assertInstanceOf('core_tests\event\unittest_executed', $event2); + + $this->assertEquals($event1->get_context(), $event2->get_context()); + + // Now test problematic data. + $data3 = $data1; + $data3['eventname'] = '\\a\\b\\c'; + $event3 = \core\event\base::restore($data3); + $this->assertFalse($event3, 'Class name must match'); + + $data4 = $data1; + unset($data4['userid']); + $event4 = \core\event\base::restore($data4); + $this->assertInstanceOf('core_tests\event\unittest_executed', $event4); + $this->assertDebuggingCalled(); + + $data5 = $data1; + $data5['xx'] = 'xx'; + $event5 = \core\event\base::restore($data5); + $this->assertInstanceOf('core_tests\event\unittest_executed', $event5); + $this->assertDebuggingNotCalled(); + + } + + public function test_trigger_problems() { + $event = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>5, 'xx'=>10))); + $event->trigger(); + try { + $event->trigger(); + $this->fail('Exception expected on double trigger'); + } catch (Exception $e) { + $this->assertInstanceOf('coding_exception', $e); + } + + $data = $event->get_data(); + $restored = \core_tests\event\unittest_executed::restore($data); + $this->assertTrue($restored->is_triggered()); + $this->assertTrue($restored->is_restored()); + + try { + $restored->trigger(); + $this->fail('Exception expected on triggering of restored event'); + } catch (\moodle_exception $e) { + $this->assertInstanceOf('coding_exception', $e); + } + } + + public function test_bad_events() { + $event = \core_tests\event\bad_event1::create(); + try { + $event->trigger(); + $this->fail('Exception expected when $data not valid'); + } catch (\moodle_exception $e) { + $this->assertInstanceOf('\coding_exception', $e); + } + + $event = \core_tests\event\bad_event2::create(); + try { + $event->trigger(); + $this->fail('Exception expected when $data not valid'); + } catch (\moodle_exception $e) { + $this->assertInstanceOf('\coding_exception', $e); + } + + $event = \core_tests\event\bad_event3::create(); + $event->trigger(); + $this->assertDebuggingCalled(); + + $event = \core_tests\event\bad_event4::create(); + $event->trigger(); + $this->assertDebuggingCalled(); + + $event = \core_tests\event\bad_event5::create(); + $event->trigger(); + $this->assertDebuggingCalled(); + } + + public function test_problematic_events() { + global $CFG; + $event1 = \core_tests\event\problematic_event1::create(); + $this->assertDebuggingNotCalled(); + + $event2 = \core_tests\event\problematic_event1::create(array('xxx'=>0)); + $this->assertDebuggingCalled(); + + $CFG->debug = 0; + $event3 = \core_tests\event\problematic_event1::create(array('xxx'=>0)); + $this->assertDebuggingNotCalled(); + $CFG->debug = E_ALL | E_STRICT; + + $event4 = \core_tests\event\problematic_event1::create(array('extra'=>array('a'=>1))); + $event4->trigger(); + $this->assertDebuggingNotCalled(); + + $event5 = \core_tests\event\problematic_event1::create(array('extra'=>(object)array('a'=>1))); + $this->assertDebuggingNotCalled(); + $event5->trigger(); + $this->assertDebuggingCalled(); + + $url = new moodle_url('/admin/'); + $event6 = \core_tests\event\problematic_event1::create(array('extra'=>array('a'=>$url))); + $this->assertDebuggingNotCalled(); + $event6->trigger(); + $this->assertDebuggingCalled(); + } + + public function test_record_cache() { + global $DB; + + $event = \core_tests\event\unittest_executed::create(array('courseid'=>1, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>1, 'xx'=>10))); + $course1 = $DB->get_record('course', array('id'=>1)); + $this->assertNotEmpty($course1); + + $event->add_cached_record('course', $course1); + + $result = $event->get_cached_record('course', 1, $course1); + $this->assertSame($course1, $result); + + $user = $event->get_cached_record('user', 1); + $this->assertEquals(1, $user->id); + $this->assertSame('guest', $user->username); + } +} diff --git a/lib/tests/eventslib_test.php b/lib/tests/eventslib_test.php index 82e61e5e678..e19209b1474 100644 --- a/lib/tests/eventslib_test.php +++ b/lib/tests/eventslib_test.php @@ -27,7 +27,7 @@ defined('MOODLE_INTERNAL') || die(); -class eventslib_testcase extends advanced_testcase { +class core_eventslib_testcase extends advanced_testcase { /** * Create temporary entries in the database for these tests. diff --git a/lib/tests/fixtures/event_fixtures.php b/lib/tests/fixtures/event_fixtures.php new file mode 100644 index 00000000000..e0954dc0cef --- /dev/null +++ b/lib/tests/fixtures/event_fixtures.php @@ -0,0 +1,140 @@ +. + +namespace core_tests\event; + +/** + * Fixtures for new event testing. + * + * @package core + * @category phpunit + * @copyright 2013 Petr Skoda {@link http://skodak.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + + +class unittest_executed extends \core\event\base { + public $nest = false; + + protected function init() { + $this->data['crud'] = 'u'; + $this->data['level'] = 10; + } + + public function get_url() { + return new moodle_url('/somepath/somefile.php', array('id'=>$this->data['extra']['sample'])); + } + + public function get_legacy_eventname() { + return 'test_legacy'; + } + + public function get_legacy_eventdata() { + return array($this->data['courseid'], $this->data['extra']['sample']); + } + + public function get_legacy_logdata() { + return array($this->data['courseid'], 'core_unittest', 'view', 'unittest.php?id='.$this->data['extra']['sample']); + } +} + + +class unittest_observer { + public static $info = array(); + public static $event = array(); + + public static function reset() { + self::$info = array(); + self::$event = array(); + } + + public static function observe_one(unittest_executed $event) { + self::$info[] = 'observe_one-'.$event->courseid; + self::$event[] = $event; + } + + public static function external_observer(\core\event\base $event) { + self::$info[] = 'external_observer-'.$event->courseid; + self::$event[] = $event; + } + + public static function broken_observer(\core\event\base $event) { + self::$info[] = 'broken_observer-'.$event->courseid; + self::$event[] = $event; + throw new \Exception('someerror'); + } + + public static function observe_all(unittest_executed $event) { + self::$event[] = $event; + if ($event->nest) { + self::$info[] = 'observe_all-nesting-'.$event->courseid; + unittest_executed::create(array('courseid'=>3, 'context'=>\context_system::instance(), 'extra'=>array('sample'=>666, 'xx'=>666)))->trigger(); + } else { + self::$info[] = 'observe_all-'.$event->courseid; + } + } + + public static function legacy_handler($data) { + self::$info[] = 'legacy_handler-'.$data[0]; + self::$event[] = $data; + } +} + +class bad_event1 extends \core\event\base { + protected function init() { + //$this->data['crud'] = 'u'; + $this->data['level'] = 10; + } +} + +class bad_event2 extends \core\event\base { + protected function init() { + $this->data['crud'] = 'u'; + //$this->data['level'] = 10; + } +} + +class bad_event3 extends \core\event\base { + protected function init() { + $this->data['crud'] = 'u'; + $this->data['level'] = 10; + unset($this->data['courseid']); + } +} + +class bad_event4 extends \core\event\base { + protected function init() { + $this->data['crud'] = 'u'; + $this->data['level'] = 10; + $this->data['xxx'] = 1; + } +} + +class bad_event5 extends \core\event\base { + protected function init() { + $this->data['crud'] = 'x'; + $this->data['level'] = 10; + } +} + +class problematic_event1 extends \core\event\base { + protected function init() { + $this->data['crud'] = 'u'; + $this->data['level'] = 10; + } +} diff --git a/lib/tests/fixtures/events.php b/lib/tests/fixtures/events.php index 5d2e657b609..8121e4c11c0 100644 --- a/lib/tests/fixtures/events.php +++ b/lib/tests/fixtures/events.php @@ -37,6 +37,14 @@ $handlers = array ( 'handlerfunction' => array('eventslib_sample_handler_class', 'static_method'), 'schedule' => 'cron', 'internal' => 1, - ) + ), + + 'test_legacy' => array ( + 'handlerfile' => '/lib/tests/event_test.php', + 'handlerfunction' => '\core_tests\event\unittest_observer::legacy_handler', + 'schedule' => 'instant', + 'internal' => 1, + ), + );