diff --git a/favourites/classes/local/entity/favourite.php b/favourites/classes/local/entity/favourite.php new file mode 100644 index 00000000000..a10a8f27014 --- /dev/null +++ b/favourites/classes/local/entity/favourite.php @@ -0,0 +1,77 @@ +. + +/** + * Contains the favourite class, each instance being a representation of a DB row for the 'favourite' table. + * + * @package core_favourites + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_favourites\local\entity; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains the favourite class, each instance being a representation of a DB row for the 'favourite' table. + * + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class favourite { + /** @var int $id the id of the favourite.*/ + public $id; + + /** @var string $component the frankenstyle name of the component containing the favourited item. E.g. 'core_course'.*/ + public $component; + + /** @var string $itemtype the type of the item being marked as a favourite. E.g. 'course', 'conversation', etc.*/ + public $itemtype; + + /** @var int $itemid the id of the item that is being marked as a favourite. e.g course->id, conversation->id, etc.*/ + public $itemid; + + /** @var int $contextid the id of the context in which this favourite was created.*/ + public $contextid; + + /** @var int $userid the id of user who owns this favourite.*/ + public $userid; + + /** @var int $ordering the ordering of the favourite within it's favourite area.*/ + public $ordering; + + /** @var int $timecreated the time at which the favourite was created.*/ + public $timecreated; + + /** @var int $timemodified the time at which the last modification of the favourite took place.*/ + public $timemodified; + + /** + * Favourite constructor. + * @param string $component the frankenstyle name of the component containing the favourited item. E.g. 'core_course'. + * @param string $itemtype the type of the item being marked as a favourite. E.g. 'course', 'conversation', etc. + * @param int $itemid the id of the item that is being marked as a favourite. e.g course->id, conversation->id, etc. + * @param int $contextid the id of the context in which this favourite was created. + * @param int $userid the id of user who owns this favourite. + */ + public function __construct(string $component, string $itemtype, int $itemid, int $contextid, int $userid) { + $this->component = $component; + $this->itemtype = $itemtype; + $this->itemid = $itemid; + $this->contextid = $contextid; + $this->userid = $userid; + } +} diff --git a/favourites/classes/local/repository/favourite_repository.php b/favourites/classes/local/repository/favourite_repository.php new file mode 100644 index 00000000000..2b94f91caec --- /dev/null +++ b/favourites/classes/local/repository/favourite_repository.php @@ -0,0 +1,331 @@ +. +/** + * Contains the favourite_repository class, responsible for CRUD operations for favourites. + * + * @package core_favourites + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_favourites\local\repository; +use \core_favourites\local\entity\favourite; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class favourite_repository. + * + * This class handles persistence of favourites. Favourites from all areas are supported by this repository. + * + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class favourite_repository implements favourite_repository_interface { + + /** + * @var string the name of the table which favourites are stored in. + */ + protected $favouritetable = 'favourite'; + + /** + * Get a favourite object, based on a full record. + * @param \stdClass $record the record we wish to hydrate. + * @return favourite the favourite record. + */ + protected function get_favourite_from_record(\stdClass $record) : favourite { + $favourite = new favourite( + $record->component, + $record->itemtype, + $record->itemid, + $record->contextid, + $record->userid + ); + $favourite->id = $record->id; + $favourite->ordering = $record->ordering ?? null; + $favourite->timecreated = $record->timecreated ?? null; + $favourite->timemodified = $record->timemodified ?? null; + + return $favourite; + } + + /** + * Get a list of favourite objects, based on a list of records. + * @param array $records the record we wish to hydrate. + * @return array the list of favourites. + */ + protected function get_list_of_favourites_from_records(array $records) { + $list = []; + foreach ($records as $index => $record) { + $list[$index] = $this->get_favourite_from_record($record); + } + return $list; + } + + /** + * Add a favourite to the repository. + * + * @param favourite $favourite the favourite to add. + * @return favourite the favourite which has been stored. + * @throws \dml_exception if any database errors are encountered. + * @throws \moodle_exception if the favourite has missing or invalid properties. + */ + public function add(favourite $favourite) : favourite { + global $DB; + $this->validate($favourite); + $favourite = (array)$favourite; + $time = time(); + $favourite['timecreated'] = $time; + $favourite['timemodified'] = $time; + $id = $DB->insert_record($this->favouritetable, $favourite); + return $this->find($id); + } + + /** + * Add a collection of favourites to the repository. + * + * @param array $items the list of favourites to add. + * @return array the list of favourites which have been stored. + * @throws \dml_exception if any database errors are encountered. + * @throws \moodle_exception if any of the favourites have missing or invalid properties. + */ + public function add_all(array $items) : array { + global $DB; + $time = time(); + foreach ($items as $item) { + $this->validate($item); + $favourite = (array)$item; + $favourite['timecreated'] = $time; + $favourite['timemodified'] = $time; + $ids[] = $DB->insert_record($this->favouritetable, $favourite); + } + list($insql, $params) = $DB->get_in_or_equal($ids); + $records = $DB->get_records_select($this->favouritetable, "id $insql", $params); + return $this->get_list_of_favourites_from_records($records); + } + + /** + * Find a favourite by id. + * + * @param int $id the id of the favourite. + * @return favourite the favourite. + * @throws \dml_exception if any database errors are encountered. + */ + public function find(int $id) : favourite { + global $DB; + $record = $DB->get_record($this->favouritetable, ['id' => $id], '*', MUST_EXIST); + return $this->get_favourite_from_record($record); + } + + /** + * Return all items matching the supplied criteria (a [key => value,..] list). + * + * @param array $criteria the list of key/value criteria pairs. + * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point. + * @param int $limitnum optional pagination control for returning a subset comprising this many records. + * @return array the list of favourites matching the criteria. + * @throws \dml_exception if any database errors are encountered. + */ + public function find_by(array $criteria, int $limitfrom = 0, int $limitnum = 0) : array { + global $DB; + $records = $DB->get_records($this->favouritetable, $criteria, '', '*', $limitfrom, $limitnum); + return $this->get_list_of_favourites_from_records($records); + } + + /** + * Return all items in this repository, as an array, indexed by id. + * + * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point. + * @param int $limitnum optional pagination control for returning a subset comprising this many records. + * @return array the list of all favourites stored within this repository. + * @throws \dml_exception if any database errors are encountered. + */ + public function find_all(int $limitfrom = 0, int $limitnum = 0) : array { + global $DB; + $records = $DB->get_records($this->favouritetable, null, '', '*', $limitfrom, $limitnum); + return $this->get_list_of_favourites_from_records($records); + } + + /** + * Find a specific favourite, based on the properties known to identify it. + * + * Used if we don't know its id. + * + * @param int $userid the id of the user to which the favourite belongs. + * @param string $component the frankenstyle component name. + * @param string $itemtype the type of the favourited item. + * @param int $itemid the id of the item which was favourited (not the favourite's id). + * @param int $contextid the contextid of the item which was favourited. + * @return favourite the favourite. + * @throws \dml_exception if any database errors are encountered or if the record could not be found. + */ + public function find_favourite(int $userid, string $component, string $itemtype, int $itemid, int $contextid) : favourite { + global $DB; + // Favourites model: We know that only one favourite can exist based on these properties. + $record = $DB->get_record($this->favouritetable, [ + 'userid' => $userid, + 'component' => $component, + 'itemtype' => $itemtype, + 'itemid' => $itemid, + 'contextid' => $contextid + ], '*', MUST_EXIST); + return $this->get_favourite_from_record($record); + } + + /** + * Check whether a favourite exists in this repository, based on its id. + * + * @param int $id the id to search for. + * @return bool true if the favourite exists, false otherwise. + * @throws \dml_exception if any database errors are encountered. + */ + public function exists(int $id) : bool { + global $DB; + return $DB->record_exists($this->favouritetable, ['id' => $id]); + } + + /** + * Update a favourite. + * + * @param favourite $favourite the favourite to update. + * @return favourite the updated favourite. + * @throws \dml_exception if any database errors are encountered. + */ + public function update(favourite $favourite) : favourite { + global $DB; + $time = time(); + $favourite->timemodified = $time; + $DB->update_record($this->favouritetable, $favourite); + return $this->find($favourite->id); + } + + /** + * Delete a favourite, by id. + * + * @param int $id the id of the favourite to delete. + * @throws \dml_exception if any database errors are encountered. + */ + public function delete(int $id) { + global $DB; + $DB->delete_records($this->favouritetable, ['id' => $id]); + } + + /** + * Return the total number of favourites in this repository. + * + * @return int the total number of items. + * @throws \dml_exception if any database errors are encountered. + */ + public function count() : int { + global $DB; + return $DB->count_records($this->favouritetable); + } + + /** + * Check for the existence of a favourite item in the specified area. + * + * A favourite item is identified by the itemid/contextid pair. + * An area is identified by the component/itemtype pair. + * + * @param int $userid the id of user to whom the favourite belongs. + * @param string $component the frankenstyle component name. + * @param string $itemtype the type of the favourited item. + * @param int $itemid the id of the item which was favourited (not the favourite's id). + * @param int $contextid the contextid of the item which was favourited. + * @return bool true if the favourited item exists, false otherwise. + * @throws \dml_exception if any database errors are encountered. + */ + public function exists_by_area(int $userid, string $component, string $itemtype, int $itemid, int $contextid) : bool { + global $DB; + return $DB->record_exists($this->favouritetable, + [ + 'userid' => $userid, + 'component' => $component, + 'itemtype' => $itemtype, + 'itemid' => $itemid, + 'contextid' => $contextid + ] + ); + } + + /** + * Delete all favourites within the component/itemtype. + * + * @param int $userid the id of the user to whom the favourite belongs. + * @param string $component the frankenstyle component name. + * @param string $itemtype the type of the favourited item. + * @throws \dml_exception if any database errors are encountered. + */ + public function delete_by_area(int $userid, string $component, string $itemtype) { + global $DB; + $DB->delete_records($this->favouritetable, + [ + 'userid' => $userid, + 'component' => $component, + 'itemtype' => $itemtype + ] + ); + } + + /** + * Return the number of user favourites matching the specified criteria. + * + * @param array $criteria the list of key/value criteria pairs. + * @return int the number of favourites matching the criteria. + * @throws \dml_exception if any database errors are encountered. + */ + public function count_by(array $criteria) : int { + global $DB; + return $DB->count_records($this->favouritetable, $criteria); + } + + /** + * Basic validation, confirming we have the minimum field set needed to save a record to the store. + * + * @param favourite $favourite the favourite record to validate. + * @throws \moodle_exception if the supplied favourite has missing or unsupported fields. + */ + protected function validate(favourite $favourite) { + + $favourite = (array)$favourite; + + // The allowed fields, and whether or not each is required to create a record. + // The timecreated, timemodified and id fields are generated during create/update. + $allowedfields = [ + 'userid' => true, + 'component' => true, + 'itemtype' => true, + 'itemid' => true, + 'contextid' => true, + 'ordering' => false, + 'timecreated' => false, + 'timemodified' => false, + 'id' => false + ]; + + $requiredfields = array_filter($allowedfields, function($field) { + return $field; + }); + + if ($missingfields = array_keys(array_diff_key($requiredfields, $favourite))) { + throw new \moodle_exception("Missing object property(s) '" . join(', ', $missingfields) . "'."); + } + + // If the record contains fields we don't allow, throw an exception. + if ($unsupportedfields = array_keys(array_diff_key($favourite, $allowedfields))) { + throw new \moodle_exception("Unexpected object property(s) '" . join(', ', $unsupportedfields) . "'."); + } + } +} diff --git a/favourites/classes/local/repository/favourite_repository_interface.php b/favourites/classes/local/repository/favourite_repository_interface.php new file mode 100644 index 00000000000..138872a974a --- /dev/null +++ b/favourites/classes/local/repository/favourite_repository_interface.php @@ -0,0 +1,117 @@ +. +/** + * Contains the favourite_repository interface. + * + * @package core_favourites + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_favourites\local\repository; +use \core_favourites\local\entity\favourite; + +defined('MOODLE_INTERNAL') || die(); + +/** + * The favourite_repository interface, defining the basic CRUD operations for favourite type items within core_favourites. + */ +interface favourite_repository_interface { + /** + * Add one item to this repository. + * + * @param favourite $item the item to add. + * @return favourite the item which was added. + */ + public function add(favourite $item) : favourite; + + /** + * Add all the items in the list to this repository. + * + * @param array $items the list of items to add. + * @return array the list of items added to this repository. + */ + public function add_all(array $items) : array; + + /** + * Find an item in this repository based on its id. + * + * @param int $id the id of the item. + * @return favourite the item. + */ + public function find(int $id) : favourite; + + /** + * Find all items in this repository. + * + * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point. + * @param int $limitnum optional pagination control for returning a subset comprising this many records. + * @return array list of all items in this repository. + */ + public function find_all(int $limitfrom = 0, int $limitnum = 0) : array; + + /** + * Find all items with attributes matching certain values. + * + * @param array $criteria the array of attribute/value pairs. + * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point. + * @param int $limitnum optional pagination control for returning a subset comprising this many records. + * @return array the list of items matching the criteria. + */ + public function find_by(array $criteria, int $limitfrom = 0, int $limitnum = 0) : array; + + /** + * Check whether an item exists in this repository, based on its id. + * + * @param int $id the id to search for. + * @return bool true if the item could be found, false otherwise. + */ + public function exists(int $id) : bool; + + /** + * Return the total number of items in this repository. + * + * @return int the total number of items. + */ + public function count() : int; + + /** + * Update an item within this repository. + * + * @param favourite $item the item to update. + * @return favourite the updated item. + */ + public function update(favourite $item) : favourite; + + /** + * Delete an item by id. + * + * @param int $id the id of the item to delete. + * @return void + */ + public function delete(int $id); + + /** + * Find a single favourite, based on it's unique identifiers. + * + * @param int $userid the id of the user to which the favourite belongs. + * @param string $component the frankenstyle component name. + * @param string $itemtype the type of the favourited item. + * @param int $itemid the id of the item which was favourited (not the favourite's id). + * @param int $contextid the contextid of the item which was favourited. + * @return favourite the favourite. + */ + public function find_favourite(int $userid, string $component, string $itemtype, int $itemid, int $contextid) : favourite; +} diff --git a/favourites/classes/local/service/user_favourite_service.php b/favourites/classes/local/service/user_favourite_service.php new file mode 100644 index 00000000000..d3b6f6d99b9 --- /dev/null +++ b/favourites/classes/local/service/user_favourite_service.php @@ -0,0 +1,138 @@ +. + +/** + * Contains the user_favourite_service class, part of the service layer for the favourites subsystem. + * + * @package core_favourites + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_favourites\local\service; +use \core_favourites\local\entity\favourite; +use \core_favourites\local\repository\favourite_repository_interface; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class service, providing an single API for interacting with the favourites subsystem for a SINGLE USER. + * + * This class is responsible for exposing key operations (add, remove, find) and enforces any business logic necessary to validate + * authorization/data integrity for these operations. + * + * All object persistence is delegated to the favourite_repository_interface object. + * + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_favourite_service { + + /** @var favourite_repository_interface $repo the favourite repository object. */ + protected $repo; + + /** @var int $userid the id of the user to which this favourites service is scoped. */ + protected $userid; + + /** + * The user_favourite_service constructor. + * + * @param \context_user $usercontext The context of the user to which this service operations are scoped. + * @param \core_favourites\local\repository\favourite_repository_interface $repository a favourites repository. + */ + public function __construct(\context_user $usercontext, favourite_repository_interface $repository) { + $this->repo = $repository; + $this->userid = $usercontext->instanceid; + } + + /** + * Favourite an item defined by itemid/context, in the area defined by component/itemtype. + * + * @param string $component the frankenstyle component name. + * @param string $itemtype the type of the item being favourited. + * @param int $itemid the id of the item which is to be favourited. + * @param \context $context the context in which the item is to be favourited. + * @param int|null $ordering optional ordering integer used for sorting the favourites in an area. + * @return favourite the favourite, once created. + * @throws \moodle_exception if the component name is invalid, or if the repository encounters any errors. + */ + public function create_favourite(string $component, string $itemtype, int $itemid, \context $context, + int $ordering = null) : favourite { + // Access: Any component can ask to favourite something, we can't verify access to that 'something' here though. + + // Validate the component name. + if (!in_array($component, \core_component::get_component_names())) { + throw new \moodle_exception("Invalid component name '$component'"); + } + + $favourite = new favourite($component, $itemtype, $itemid, $context->id, $this->userid); + $favourite->ordering = $ordering > 0 ? $ordering : null; + return $this->repo->add($favourite); + } + + /** + * Find a list of favourites, by type, where type is the component/itemtype pair. + * + * E.g. "Find all favourite courses" might result in: + * $favcourses = find_favourites_by_type('core_course', 'course'); + * + * @param string $component the frankenstyle component name. + * @param string $itemtype the type of the favourited item. + * @param int $limitfrom optional pagination control for returning a subset of records, starting at this point. + * @param int $limitnum optional pagination control for returning a subset comprising this many records. + * @return array the list of favourites found. + * @throws \moodle_exception if the component name is invalid, or if the repository encounters any errors. + */ + public function find_favourites_by_type(string $component, string $itemtype, int $limitfrom = 0, int $limitnum = 0) : array { + if (!in_array($component, \core_component::get_component_names())) { + throw new \moodle_exception("Invalid component name '$component'"); + } + return $this->repo->find_by( + [ + 'userid' => $this->userid, + 'component' => $component, + 'itemtype' => $itemtype + ], + $limitfrom, + $limitnum + ); + } + + /** + * Delete a favourite item from an area and from within a context. + * + * E.g. delete a favourite course from the area 'core_course', 'course' with itemid 3 and from within the CONTEXT_USER context. + * + * @param string $component the frankenstyle component name. + * @param string $itemtype the type of the favourited item. + * @param int $itemid the id of the item which was favourited (not the favourite's id). + * @param \context $context the context of the item which was favourited. + * @throws \moodle_exception if the user does not control the favourite, or it doesn't exist. + */ + public function delete_favourite(string $component, string $itemtype, int $itemid, \context $context) { + if (!in_array($component, \core_component::get_component_names())) { + throw new \moodle_exception("Invalid component name '$component'"); + } + + // Business logic: check the user owns the favourite. + try { + $favourite = $this->repo->find_favourite($this->userid, $component, $itemtype, $itemid, $context->id); + } catch (\moodle_exception $e) { + throw new \moodle_exception("Favourite does not exist for the user. Cannot delete."); + } + + $this->repo->delete($favourite->id); + } +} diff --git a/favourites/classes/privacy/provider.php b/favourites/classes/privacy/provider.php new file mode 100644 index 00000000000..74c9b28fe02 --- /dev/null +++ b/favourites/classes/privacy/provider.php @@ -0,0 +1,143 @@ +. + +/** + * Privacy class for requesting user data for the favourites subsystem. + * + * @package core_favourites + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_favourites\privacy; + +defined('MOODLE_INTERNAL') || die(); + +use \core_privacy\local\metadata\collection; +use \core_privacy\local\request\context; +use \core_privacy\local\request\approved_contextlist; + +/** + * Privacy class for requesting user data. + * + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\subsystem\plugin_provider { + + /** + * Returns metadata about this system. + * + * @param collection $collection The initialised collection to add items to. + * @return collection A listing of user data stored through this system. + */ + public static function get_metadata(collection $collection) : collection { + return $collection->add_database_table('favourite', [ + 'userid' => 'privacy:metadata:favourite:userid', + 'component' => 'privacy:metadata:favourite:component', + 'itemtype' => 'privacy:metadata:favourite:itemtype', + 'itemid' => 'privacy:metadata:favourite:itemid', + 'ordering' => 'privacy:metadata:favourite:ordering', + 'timecreated' => 'privacy:metadata:favourite:timecreated', + 'timemodified' => 'privacy:metadata:favourite:timemodified', + ], 'privacy:metadata:favourite'); + } + + /** + * Provide a list of contexts which have favourites for the user, in the respective area (component/itemtype combination). + * + * This method is to be called by consumers of the favourites subsystem (plugins), in their get_contexts_for_userid() method, + * to add the contexts for items which may have been favourited, but would normally not be reported as having user data by the + * plugin responsible for them. + * + * Consider an example: Favourite courses. + * Favourite courses will be handled by the core_course subsystem and courses can be favourited at site context. + * + * Now normally, the course provider method get_contexts_for_userid() would report the context of any courses the user is in. + * Then, we'd export data for those contexts. This won't include courses the user has favourited, but is not a member of. + * + * To report the full list, the course provider needs to be made aware of the contexts of any courses the user may have marked + * as favourites. Course will need to ask th favourites subsystem for this - a call to add_contexts_for_userid($userid). + * + * Once called, if a course has been marked as a favourite, at site context, then we'd return the site context. During export, + * the consumer (course), just looks at all contexts and decides whether to export favourite courses for each one. + * + * @param \core_privacy\local\request\contextlist $contextlist + * @param int $userid The id of the user in scope. + * @param string $component the frankenstyle component name. + * @param string $itemtype the type of the favourited items. + */ + public static function add_contexts_for_userid(\core_privacy\local\request\contextlist $contextlist, int $userid, + string $component, string $itemtype = null) { + $sql = "SELECT contextid + FROM {favourite} f + WHERE userid = :userid + AND component = :component"; + if (!is_null($itemtype)) { + $sql .= "AND itemtype = :itemtype"; + } + $params = ['userid' => $userid, 'component' => $component, 'itemtype' => $itemtype]; + $contextlist->add_from_sql($sql, $params); + } + + /** + * Delete all favourites for all users in the specified contexts, and component area. + * + * @param \context $context The context to which deletion is scoped. + * @param string $component The favourite's component name. + * @param string $itemtype The favourite's itemtype. + * @throws \dml_exception if any errors are encountered during deletion. + */ + public static function delete_favourites_for_all_users(\context $context, string $component, string $itemtype) { + global $DB; + + $params = [ + 'component' => $component, + 'itemtype' => $itemtype, + 'contextid' => $context->id + ]; + + $select = "component = :component AND itemtype =:itemtype AND contextid = :contextid"; + $DB->delete_records_select('favourite', $select, $params); + } + + /** + * Delete all favourites for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. + * @param string $component + * @param string $itemtype + * @throws \coding_exception + * @throws \dml_exception + */ + public static function delete_favourites_for_user(approved_contextlist $contextlist, string $component, string $itemtype) { + global $DB; + + $userid = $contextlist->get_user()->id; + + list($insql, $inparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + + $params = [ + 'userid' => $userid, + 'component' => $component, + 'itemtype' => $itemtype, + ]; + $params += $inparams; + + $select = "userid = :userid AND component = :component AND itemtype =:itemtype AND contextid $insql"; + $DB->delete_records_select('favourite', $select, $params); + } +} diff --git a/favourites/classes/service_factory.php b/favourites/classes/service_factory.php new file mode 100644 index 00000000000..f1580033331 --- /dev/null +++ b/favourites/classes/service_factory.php @@ -0,0 +1,49 @@ +. +/** + * Contains the service_factory, a locator for services for the favourites subsystem. + * + * Services encapsulate the business logic, and any data manipulation code, and are what clients should interact with. + * + * @package core_favourites + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_favourites; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class service_factory, providing functions for location of service objects for the favourites subsystem. + * + * This class is responsible for providing service objects to clients only. + * + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class service_factory { + + /** + * Returns a basic service object providing operations for user favourites. + * + * @param \context_user $context the context of the user to which the service should be scoped. + * @return \core_favourites\local\service\user_favourite_service the service object. + */ + public static function get_service_for_user_context(\context_user $context) : local\service\user_favourite_service { + return new local\service\user_favourite_service($context, new local\repository\favourite_repository()); + } +} + diff --git a/favourites/tests/privacy_test.php b/favourites/tests/privacy_test.php new file mode 100644 index 00000000000..409282bae80 --- /dev/null +++ b/favourites/tests/privacy_test.php @@ -0,0 +1,137 @@ +. + +/** + * Privacy tests for core_favourites. + * + * @package core_favourites + * @category test + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use \core_privacy\tests\provider_testcase; +use \core_favourites\privacy\provider; + +/** + * Unit tests for favourites/classes/privacy/provider + * + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class privacy_test extends provider_testcase { + + public function setUp() { + $this->resetAfterTest(true); + } + + /** + * Helper to set up some sample users and courses. + */ + protected function set_up_courses_and_users() { + $user1 = self::getDataGenerator()->create_user(); + $user1context = \context_user::instance($user1->id); + $user2 = self::getDataGenerator()->create_user(); + $user2context = \context_user::instance($user2->id); + $course1 = self::getDataGenerator()->create_course(); + $course2 = self::getDataGenerator()->create_course(); + $course1context = context_course::instance($course1->id); + $course2context = context_course::instance($course2->id); + return [$user1, $user2, $user1context, $user2context, $course1context, $course2context]; + } + + /** + * Test confirming that contexts of favourited items can be added to the contextlist. + */ + public function test_add_contexts_for_userid() { + list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users(); + + // Favourite 2 courses for user1 and 1 course for user2, all at the site context. + $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context); + $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context); + $systemcontext = context_system::instance(); + $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $systemcontext); + $ufservice1->create_favourite('core_course', 'course', $course2context->instanceid, $systemcontext); + $ufservice2->create_favourite('core_course', 'course', $course2context->instanceid, $systemcontext); + $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'course')); + $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course')); + + // Now, just for variety, let's assume you can favourite a course at user context, and do so for user1. + $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $user1context); + + // Now, ask the favourites privacy api to export contexts for favourites of the type we just created, for user1. + $contextlist = new \core_privacy\local\request\contextlist(); + \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $user1->id, 'core_course', 'course'); + + // Verify we have two contexts in the list for user1. + $this->assertCount(2, $contextlist->get_contextids()); + + // And verify we only have the system context returned for user2. + $contextlist = new \core_privacy\local\request\contextlist(); + \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $user2->id, 'core_course', 'course'); + $this->assertCount(1, $contextlist->get_contextids()); + } + + /** + * Test deletion of user favourites based on an approved_contextlist and component area. + */ + public function test_delete_favourites_for_user() { + list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users(); + + // Favourite 2 courses for user1 and 1 course for user2, all at the user context. + $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context); + $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context); + $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $user1context); + $ufservice1->create_favourite('core_course', 'course', $course2context->instanceid, $user1context); + $ufservice2->create_favourite('core_course', 'course', $course2context->instanceid, $user2context); + $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'course')); + $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course')); + + // Now, delete the favourites for user1 only. + $approvedcontextlist = new \core_privacy\local\request\approved_contextlist($user1, 'core_course', [$user1context->id]); + provider::delete_favourites_for_user($approvedcontextlist, 'core_course', 'course'); + + // Verify that we have no favourite courses for user1 but that the records are in tact for user2. + $this->assertCount(0, $ufservice1->find_favourites_by_type('core_course', 'course')); + $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course')); + } + + public function test_delete_favourites_for_all_users() { + list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users(); + + // Favourite 2 course modules for user1 and 1 course module for user2 all in course 1 context. + $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context); + $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context); + $ufservice1->create_favourite('core_course', 'modules', 1, $course1context); + $ufservice1->create_favourite('core_course', 'modules', 2, $course1context); + $ufservice2->create_favourite('core_course', 'modules', 3, $course1context); + + // Now, favourite a different course module for user2 in course 2. + $ufservice2->create_favourite('core_course', 'modules', 5, $course2context); + + $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'modules')); + $this->assertCount(2, $ufservice2->find_favourites_by_type('core_course', 'modules')); + + // Now, delete all course module favourites in the 'course1' context only. + provider::delete_favourites_for_all_users($course1context, 'core_course', 'modules'); + + // Verify that only a single favourite for user1 in course 1 remains. + $this->assertCount(0, $ufservice1->find_favourites_by_type('core_course', 'modules')); + $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'modules')); + } +} diff --git a/favourites/tests/repository_test.php b/favourites/tests/repository_test.php new file mode 100644 index 00000000000..60c8ed31fd4 --- /dev/null +++ b/favourites/tests/repository_test.php @@ -0,0 +1,536 @@ +. + +/** + * Testing the repository objects within core_favourites. + * + * @package core_favourites + * @category test + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use \core_favourites\local\repository\favourite_repository; +use \core_favourites\local\entity\favourite; + +/** + * Test class covering the favourite_repository. + * + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class favourite_repository_testcase extends advanced_testcase { + + public function setUp() { + $this->resetAfterTest(); + } + + // Basic setup stuff to be reused in most tests. + protected function setup_users_and_courses() { + $user1 = self::getDataGenerator()->create_user(); + $user1context = \context_user::instance($user1->id); + $user2 = self::getDataGenerator()->create_user(); + $user2context = \context_user::instance($user2->id); + $course1 = self::getDataGenerator()->create_course(); + $course2 = self::getDataGenerator()->create_course(); + $course1context = context_course::instance($course1->id); + $course2context = context_course::instance($course2->id); + return [$user1context, $user2context, $course1context, $course2context]; + } + + /** + * Verify the basic create operation can create records, and is validated. + */ + public function test_add() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite a course. + $favouritesrepo = new favourite_repository($user1context); + + $favcourse = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $timenow = time(); // Reference only, to check that the created item has a time equal to or greater than this. + $favourite = $favouritesrepo->add($favcourse); + + // Verify we get the record back. + $this->assertInstanceOf(favourite::class, $favourite); + $this->assertObjectHasAttribute('id', $favourite); + $this->assertEquals('core_course', $favourite->component); + $this->assertEquals('course', $favourite->itemtype); + + // Verify the returned object has additional properties, created as part of the add. + $this->assertObjectHasAttribute('ordering', $favourite); + $this->assertObjectHasAttribute('timecreated', $favourite); + $this->assertGreaterThanOrEqual($timenow, $favourite->timecreated); + + // Try to save the same record again and confirm the store throws an exception. + $this->expectException('dml_write_exception'); + $favouritesrepo->add($favcourse); + } + + /** + * Tests that malformed favourites cannot be saved. + */ + public function test_add_malformed_favourite() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite a course. + $favouritesrepo = new favourite_repository($user1context); + + $favcourse = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favcourse->something = 'something'; + + $this->expectException('moodle_exception'); + $favouritesrepo->add($favcourse); + } + + /** + * Tests that incomplete favourites cannot be saved. + */ + public function test_add_incomplete_favourite() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and try to favourite a course. + $favouritesrepo = new favourite_repository($user1context); + + $favcourse = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + unset($favcourse->userid); + + $this->expectException('moodle_exception'); + $favouritesrepo->add($favcourse); + } + + public function test_add_all_basic() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite several courses. + $favouritesrepo = new favourite_repository($user1context); + $favcourses = []; + + $favcourses[] = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favcourses[] = new favourite( + 'core_course', + 'course', + $course2context->instanceid, + $course2context->id, + $user1context->instanceid + ); + + $timenow = time(); // Reference only, to check that the created item has a time equal to or greater than this. + $favourites = $favouritesrepo->add_all($favcourses); + + $this->assertInternalType('array', $favourites); + $this->assertCount(2, $favourites); + foreach ($favourites as $favourite) { + // Verify we get the favourite back. + $this->assertInstanceOf(favourite::class, $favourite); + $this->assertEquals('core_course', $favourite->component); + $this->assertEquals('course', $favourite->itemtype); + + // Verify the returned object has additional properties, created as part of the add. + $this->assertObjectHasAttribute('ordering', $favourite); + $this->assertObjectHasAttribute('timecreated', $favourite); + $this->assertGreaterThanOrEqual($timenow, $favourite->timecreated); + } + + // Try to save the same record again and confirm the store throws an exception. + $this->expectException('dml_write_exception'); + $favouritesrepo->add_all($favcourses); + } + + /** + * Tests reading from the repository by instance id. + */ + public function test_find() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite a course. + $favouritesrepo = new favourite_repository($user1context); + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favourite = $favouritesrepo->add($favourite); + + // Now, from the repo, get the single favourite we just created, by id. + $userfavourite = $favouritesrepo->find($favourite->id); + $this->assertInstanceOf(favourite::class, $userfavourite); + $this->assertObjectHasAttribute('timecreated', $userfavourite); + + // Try to get a favourite we know doesn't exist. + // We expect an exception in this case. + $this->expectException(dml_exception::class); + $favouritesrepo->find(1); + } + + /** + * Test verifying that find_all() returns all favourites, or an empty array. + */ + public function test_find_all() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + $favouritesrepo = new favourite_repository($user1context); + + // Verify that for an empty repository, find_all returns an empty array. + $this->assertEquals([], $favouritesrepo->find_all()); + + // Save a favourite for 2 courses, in different areas. + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favourite2 = new favourite( + 'core_course', + 'course', + $course2context->instanceid, + $course2context->id, + $user1context->instanceid + ); + $favouritesrepo->add($favourite); + $favouritesrepo->add($favourite2); + + // Verify that find_all returns both of our favourites. + $favourites = $favouritesrepo->find_all(); + $this->assertCount(2, $favourites); + foreach ($favourites as $fav) { + $this->assertInstanceOf(favourite::class, $fav); + $this->assertObjectHasAttribute('id', $fav); + $this->assertObjectHasAttribute('timecreated', $fav); + } + } + + /** + * Testing the pagination of the find_all method. + */ + public function test_find_all_pagination() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + $favouritesrepo = new favourite_repository($user1context); + + // Verify that for an empty repository, find_all with any combination of page options returns an empty array. + $this->assertEquals([], $favouritesrepo->find_all(0, 0)); + $this->assertEquals([], $favouritesrepo->find_all(0, 10)); + $this->assertEquals([], $favouritesrepo->find_all(1, 0)); + $this->assertEquals([], $favouritesrepo->find_all(1, 10)); + + // Save 10 arbitrary favourites to the repo. + foreach (range(1, 10) as $i) { + $favourite = new favourite( + 'core_course', + 'course', + $i, + $course1context->id, + $user1context->instanceid + ); + $favouritesrepo->add($favourite); + } + + // Verify we have 10 favourites. + $this->assertEquals(10, $favouritesrepo->count()); + + // Verify we can fetch the first page of 5 records. + $favourites = $favouritesrepo->find_all(0, 5); + $this->assertCount(5, $favourites); + + // Verify we can fetch the second page. + $favourites = $favouritesrepo->find_all(5, 5); + $this->assertCount(5, $favourites); + + // Verify the third page request ends with an empty array. + $favourites = $favouritesrepo->find_all(10, 5); + $this->assertCount(0, $favourites); + } + + /** + * Test retrieval of a user's favourites for a given criteria, in this case, area. + */ + public function test_find_by() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite a course. + $favouritesrepo = new favourite_repository($user1context); + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favouritesrepo->add($favourite); + + // From the repo, get the list of favourites for the 'core_course/course' area. + $userfavourites = $favouritesrepo->find_by(['component' => 'core_course', 'itemtype' => 'course']); + $this->assertInternalType('array', $userfavourites); + $this->assertCount(1, $userfavourites); + + // Try to get a list of favourites for a non-existent area. + $userfavourites = $favouritesrepo->find_by(['component' => 'core_cannibalism', 'itemtype' => 'course']); + $this->assertInternalType('array', $userfavourites); + $this->assertCount(0, $userfavourites); + } + + /** + * Testing the pagination of the find_by method. + */ + public function test_find_by_pagination() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + $favouritesrepo = new favourite_repository($user1context); + + // Verify that for an empty repository, find_all with any combination of page options returns an empty array. + $this->assertEquals([], $favouritesrepo->find_by([], 0, 0)); + $this->assertEquals([], $favouritesrepo->find_by([], 0, 10)); + $this->assertEquals([], $favouritesrepo->find_by([], 1, 0)); + $this->assertEquals([], $favouritesrepo->find_by([], 1, 10)); + + // Save 10 arbitrary favourites to the repo. + foreach (range(1, 10) as $i) { + $favourite = new favourite( + 'core_course', + 'course', + $i, + $course1context->id, + $user1context->instanceid + ); + $favouritesrepo->add($favourite); + } + + // Verify we have 10 favourites. + $this->assertEquals(10, $favouritesrepo->count()); + + // Verify a request for a page, when no criteria match, results in an empty array. + $favourites = $favouritesrepo->find_by(['component' => 'core_message'], 0, 5); + $this->assertCount(0, $favourites); + + // Verify we can fetch a the first page of 5 records. + $favourites = $favouritesrepo->find_by(['component' => 'core_course'], 0, 5); + $this->assertCount(5, $favourites); + + // Verify we can fetch the second page. + $favourites = $favouritesrepo->find_by(['component' => 'core_course'], 5, 5); + $this->assertCount(5, $favourites); + + // Verify the third page request ends with an empty array. + $favourites = $favouritesrepo->find_by(['component' => 'core_course'], 10, 5); + $this->assertCount(0, $favourites); + } + + /** + * Test the count_by() method. + */ + public function test_count_by() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and add 2 favourites in different areas. + $favouritesrepo = new favourite_repository($user1context); + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favourite2 = new favourite( + 'core_course', + 'anothertype', + $course2context->instanceid, + $course2context->id, + $user1context->instanceid + ); + $favouritesrepo->add($favourite); + $favouritesrepo->add($favourite2); + + // Verify counts can be restricted by criteria. + $this->assertEquals(1, $favouritesrepo->count_by(['userid' => $user1context->instanceid, 'component' => 'core_course', + 'itemtype' => 'course'])); + $this->assertEquals(1, $favouritesrepo->count_by(['userid' => $user1context->instanceid, 'component' => 'core_course', + 'itemtype' => 'anothertype'])); + $this->assertEquals(0, $favouritesrepo->count_by(['userid' => $user1context->instanceid, 'component' => 'core_course', + 'itemtype' => 'nonexistenttype'])); + } + + public function test_exists() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite a course. + $favouritesrepo = new favourite_repository($user1context); + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $createdfavourite = $favouritesrepo->add($favourite); + + // Verify the existence of the favourite in the repo. + $this->assertTrue($favouritesrepo->exists($createdfavourite->id)); + + // Verify exists returns false for non-existent favourite. + $this->assertFalse($favouritesrepo->exists(1)); + } + + public function test_exists_by_area() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite two courses, in different areas. + $favouritesrepo = new favourite_repository($user1context); + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favourite2 = new favourite( + 'core_course', + 'anothertype', + $course2context->instanceid, + $course2context->id, + $user1context->instanceid + ); + $favourite1 = $favouritesrepo->add($favourite); + $favourite2 = $favouritesrepo->add($favourite2); + + // Verify the existence of the favourites. + $this->assertTrue($favouritesrepo->exists_by_area($user1context->instanceid, 'core_course', 'course', $favourite1->itemid, + $favourite1->contextid)); + $this->assertTrue($favouritesrepo->exists_by_area($user1context->instanceid, 'core_course', 'anothertype', + $favourite2->itemid, $favourite2->contextid)); + + // Verify that we can't find a favourite from one area, in another. + $this->assertFalse($favouritesrepo->exists_by_area($user1context->instanceid, 'core_course', 'anothertype', + $favourite1->itemid, $favourite1->contextid)); + } + + /** + * Test the update() method, by simulating a user changing the ordering of a favourite. + */ + public function test_update() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite a course. + $favouritesrepo = new favourite_repository($user1context); + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favourite1 = $favouritesrepo->add($favourite); + $this->assertNull($favourite1->ordering); + + // Verify we can update the ordering for 2 favourites. + $favourite1->ordering = 1; + $favourite1 = $favouritesrepo->update($favourite1); + $this->assertInstanceOf(favourite::class, $favourite1); + $this->assertAttributeEquals('1', 'ordering', $favourite1); + } + + public function test_delete() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite a course. + $favouritesrepo = new favourite_repository($user1context); + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favourite = $favouritesrepo->add($favourite); + + // Verify the existence of the favourite in the repo. + $this->assertTrue($favouritesrepo->exists($favourite->id)); + + // Now, delete the favourite and confirm it's not retrievable. + $favouritesrepo->delete($favourite->id); + $this->assertFalse($favouritesrepo->exists($favourite->id)); + } + + public function test_delete_by_area() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Create a favourites repository and favourite two courses, in different areas. + $favouritesrepo = new favourite_repository($user1context); + $favourite = new favourite( + 'core_course', + 'course', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favourite2 = new favourite( + 'core_course', + 'anothertype', + $course1context->instanceid, + $course1context->id, + $user1context->instanceid + ); + $favourite1 = $favouritesrepo->add($favourite); + $favourite2 = $favouritesrepo->add($favourite2); + + // Verify we have 2 items in the repo. + $this->assertEquals(2, $favouritesrepo->count()); + + // Try to delete by a non-existent area, and confirm it doesn't remove anything. + $favouritesrepo->delete_by_area($user1context->instanceid, 'core_course', 'donaldduck'); + $this->assertEquals(2, $favouritesrepo->count()); + + // Try to delete by a non-existent area, and confirm it doesn't remove anything. + $favouritesrepo->delete_by_area($user1context->instanceid, 'core_course', 'cat'); + $this->assertEquals(2, $favouritesrepo->count()); + + // Delete by area, and confirm we have one record left, from the 'core_course/anothertype' area. + $favouritesrepo->delete_by_area($user1context->instanceid, 'core_course', 'course'); + $this->assertEquals(1, $favouritesrepo->count()); + $this->assertFalse($favouritesrepo->exists($favourite1->id)); + $this->assertTrue($favouritesrepo->exists($favourite2->id)); + } +} diff --git a/favourites/tests/service_test.php b/favourites/tests/service_test.php new file mode 100644 index 00000000000..6600c2dccc6 --- /dev/null +++ b/favourites/tests/service_test.php @@ -0,0 +1,308 @@ +. + +/** + * Testing the service layer within core_favourites. + * + * @package core_favourites + * @category test + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +use \core_favourites\local\entity\favourite; +defined('MOODLE_INTERNAL') || die(); + +/** + * Test class covering the user_favourite_service within the service layer of favourites. + * + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class user_favourite_service_testcase extends advanced_testcase { + + public function setUp() { + $this->resetAfterTest(); + } + + // Basic setup stuff to be reused in most tests. + protected function setup_users_and_courses() { + $user1 = self::getDataGenerator()->create_user(); + $user1context = \context_user::instance($user1->id); + $user2 = self::getDataGenerator()->create_user(); + $user2context = \context_user::instance($user2->id); + $course1 = self::getDataGenerator()->create_course(); + $course2 = self::getDataGenerator()->create_course(); + $course1context = context_course::instance($course1->id); + $course2context = context_course::instance($course2->id); + return [$user1context, $user2context, $course1context, $course2context]; + } + + /** + * Generates an in-memory repository for testing, using an array store for CRUD stuff. + * + * @param array $mockstore + * @return \PHPUnit\Framework\MockObject\MockObject + */ + protected function get_mock_repository(array $mockstore) { + // This mock will just store data in an array. + $mockrepo = $this->getMockBuilder(\core_favourites\local\repository\favourite_repository_interface::class) + ->setMethods([]) + ->getMock(); + $mockrepo->expects($this->any()) + ->method('add') + ->will($this->returnCallback(function(favourite $favourite) use (&$mockstore) { + // Mock implementation of repository->add(), where an array is used instead of the DB. + // Duplicates are confirmed via the unique key, and exceptions thrown just like a real repo. + $key = $favourite->userid . $favourite->component . $favourite->itemtype . $favourite->itemid + . $favourite->contextid; + + // Check the objects for the unique key. + foreach ($mockstore as $item) { + if ($item->uniquekey == $key) { + throw new \moodle_exception('Favourite already exists'); + } + } + $index = count($mockstore); // Integer index. + $favourite->uniquekey = $key; // Simulate the unique key constraint. + $favourite->id = $index; + $mockstore[$index] = $favourite; + return $mockstore[$index]; + }) + ); + $mockrepo->expects($this->any()) + ->method('find_by') + ->will($this->returnCallback(function(array $criteria, int $limitfrom = 0, int $limitnum = 0) use (&$mockstore) { + // Check the mockstore for all objects with properties matching the key => val pairs in $criteria. + foreach ($mockstore as $index => $mockrow) { + $mockrowarr = (array)$mockrow; + if (array_diff($criteria, $mockrowarr) == []) { + $returns[$index] = $mockrow; + } + } + // Return a subset of the records, according to the paging options, if set. + if ($limitnum != 0) { + return array_slice($returns, $limitfrom, $limitnum); + } + // Otherwise, just return the full set. + return $returns; + }) + ); + $mockrepo->expects($this->any()) + ->method('find_favourite') + ->will($this->returnCallback(function(int $userid, string $comp, string $type, int $id, int $ctxid) use (&$mockstore) { + // Check the mockstore for all objects with properties matching the key => val pairs in $criteria. + $crit = ['userid' => $userid, 'component' => $comp, 'itemtype' => $type, 'itemid' => $id, 'contextid' => $ctxid]; + foreach ($mockstore as $fakerow) { + $fakerowarr = (array)$fakerow; + if (array_diff($crit, $fakerowarr) == []) { + return $fakerow; + } + } + throw new \moodle_exception("Item not found"); + }) + ); + $mockrepo->expects($this->any()) + ->method('find') + ->will($this->returnCallback(function(int $id) use (&$mockstore) { + return $mockstore[$id]; + }) + ); + $mockrepo->expects($this->any()) + ->method('exists') + ->will($this->returnCallback(function(int $id) use (&$mockstore) { + return array_key_exists($id, $mockstore); + }) + ); + $mockrepo->expects($this->any()) + ->method('delete') + ->will($this->returnCallback(function(int $id) use (&$mockstore) { + foreach ($mockstore as $mockrow) { + if ($mockrow->id == $id) { + unset($mockstore[$id]); + } + } + }) + ); + return $mockrepo; + } + + /** + * Test getting a user_favourite_service from the static locator. + */ + public function test_get_service_for_user_context() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + $userservice = \core_favourites\service_factory::get_service_for_user_context($user1context); + $this->assertInstanceOf(\core_favourites\local\service\user_favourite_service::class, $userservice); + } + + /** + * Test confirming an item can be favourited only once. + */ + public function test_create_favourite_basic() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Get a user_favourite_service for a user. + $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB. + $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo); + + // Favourite a course. + $favourite1 = $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context); + $this->assertObjectHasAttribute('id', $favourite1); + + // Try to favourite the same course again. + $this->expectException('moodle_exception'); + $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context); + } + + /** + * Test confirming that an exception is thrown if trying to favourite an item for a non-existent component. + */ + public function test_create_favourite_nonexistent_component() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Get a user_favourite_service for the user. + $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB. + $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo); + + // Try to favourite something in a non-existent component. + $this->expectException('moodle_exception'); + $user1service->create_favourite('core_cccourse', 'my_area', $course1context->instanceid, $course1context); + } + + /** + * Test fetching favourites for single user, by area. + */ + public function test_find_favourites_by_type_single_user() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Get a user_favourite_service for the user. + $repo = $this->get_mock_repository([]); // Mock repository, using the array as a mock DB. + $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo); + + // Favourite 2 courses, in separate areas. + $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context); + $fav2 = $service->create_favourite('core_course', 'anothertype', $course2context->instanceid, $course2context); + + // Verify we can get favourites by area. + $favourites = $service->find_favourites_by_type('core_course', 'course'); + $this->assertInternalType('array', $favourites); + $this->assertCount(1, $favourites); // We only get favourites for the 'core_course/course' area. + $this->assertAttributeEquals($fav1->id, 'id', $favourites[$fav1->id]); + + $favourites = $service->find_favourites_by_type('core_course', 'anothertype'); + $this->assertInternalType('array', $favourites); + $this->assertCount(1, $favourites); // We only get favourites for the 'core_course/course' area. + $this->assertAttributeEquals($fav2->id, 'id', $favourites[$fav2->id]); + } + + /** + * Make sure the find_favourites_by_type() method only returns favourites for the scoped user. + */ + public function test_find_favourites_by_type_multiple_users() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Get a user_favourite_service for 2 users. + $repo = $this->get_mock_repository([]); + $user1service = new \core_favourites\local\service\user_favourite_service($user1context, $repo); + $user2service = new \core_favourites\local\service\user_favourite_service($user2context, $repo); + + // Now, as each user, favourite the same course. + $fav1 = $user1service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context); + $fav2 = $user2service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context); + + // Verify find_favourites_by_type only returns results for the user to which the service is scoped. + $user1favourites = $user1service->find_favourites_by_type('core_course', 'course'); + $this->assertInternalType('array', $user1favourites); + $this->assertCount(1, $user1favourites); // We only get favourites for the 'core_course/course' area for $user1. + $this->assertAttributeEquals($fav1->id, 'id', $user1favourites[$fav1->id]); + + $user2favourites = $user2service->find_favourites_by_type('core_course', 'course'); + $this->assertInternalType('array', $user2favourites); + $this->assertCount(1, $user2favourites); // We only get favourites for the 'core_course/course' area for $user2. + $this->assertAttributeEquals($fav2->id, 'id', $user2favourites[$fav2->id]); + } + + /** + * Test confirming that an exception is thrown if trying to get favourites for a non-existent component. + */ + public function test_find_favourites_by_type_nonexistent_component() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Get a user_favourite_service for the user. + $repo = $this->get_mock_repository([]); + $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo); + + // Verify we get an exception if we try to search for favourites in an invalid component. + $this->expectException('moodle_exception'); + $service->find_favourites_by_type('cccore_notreal', 'something'); + } + + /** + * Test confirming the pagination support for the find_favourites_by_type() method. + */ + public function test_find_favourites_by_type_pagination() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Get a user_favourite_service for the user. + $repo = $this->get_mock_repository([]); + $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo); + + // Favourite 10 arbitrary items. + foreach (range(1, 10) as $i) { + $service->create_favourite('core_course', 'course', $i, $course1context); + } + + // Verify we have 10 favourites. + $this->assertCount(10, $service->find_favourites_by_type('core_course', 'course')); + + // Verify we get back 5 favourites for page 1. + $favourites = $service->find_favourites_by_type('core_course', 'course', 0, 5); + $this->assertCount(5, $favourites); + + // Verify we get back 5 favourites for page 2. + $favourites = $service->find_favourites_by_type('core_course', 'course', 5, 5); + $this->assertCount(5, $favourites); + + // Verify we get back an empty array if querying page 3. + $favourites = $service->find_favourites_by_type('core_course', 'course', 10, 5); + $this->assertCount(0, $favourites); + } + + /** + * Test confirming the basic deletion behaviour. + */ + public function test_delete_favourite_basic() { + list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses(); + + // Get a user_favourite_service for the user. + $repo = $this->get_mock_repository([]); + $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo); + + // Favourite a course. + $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context); + $this->assertTrue($repo->exists($fav1->id)); + + // Delete the favourite. + $service->delete_favourite('core_course', 'course', $course1context->instanceid, $course1context); + + // Verify the favourite doesn't exist. + $this->assertFalse($repo->exists($fav1->id)); + + // Try to delete a favourite which we know doesn't exist. + $this->expectException(\moodle_exception::class); + $service->delete_favourite('core_course', 'course', $course1context->instanceid, $course1context); + } +} diff --git a/lang/en/favourites.php b/lang/en/favourites.php new file mode 100644 index 00000000000..ea13dd2f1af --- /dev/null +++ b/lang/en/favourites.php @@ -0,0 +1,30 @@ +. +/** + * Strings for component 'favourites', language 'en', branch 'master' + * + * @package core_favourites + * @copyright 2018 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +$string['privacy:metadata:favourite'] = 'Stores references to items which have been marked as favourites.'; +$string['privacy:metadata:favourite:component'] = 'The component to which the favourite belongs to. E.g. core_user.'; +$string['privacy:metadata:favourite:itemid'] = 'The identifier for the item being marked as a favourite.'; +$string['privacy:metadata:favourite:itemtype'] = 'The type of the favourite item. E.g. course.'; +$string['privacy:metadata:favourite:ordering'] = 'A number used to order the favourites of the same type.'; +$string['privacy:metadata:favourite:timecreated'] = 'The time at which the item was marked as a favourite.'; +$string['privacy:metadata:favourite:timemodified'] = 'The time at which favourite was last modified.'; +$string['privacy:metadata:favourite:userid'] = 'The user who created the favourite.'; diff --git a/lib/classes/component.php b/lib/classes/component.php index f7debb8ed81..11ce5a6d552 100644 --- a/lib/classes/component.php +++ b/lib/classes/component.php @@ -442,6 +442,7 @@ $cache = '.var_export($cache, true).'; 'edufields' => null, 'enrol' => $CFG->dirroot.'/enrol', 'error' => null, + 'favourites' => $CFG->dirroot . '/favourites', 'filepicker' => null, 'fileconverter' => $CFG->dirroot.'/files/converter', 'files' => $CFG->dirroot.'/files', @@ -1277,4 +1278,31 @@ $cache = '.var_export($cache, true).'; } return $components; } + + /** + * Returns a list of frankenstyle component names. + * + * E.g. + * [ + * 'core_course', + * 'core_message', + * 'mod_assign', + * ... + * ] + * @return array the list of frankenstyle component names. + */ + public static function get_component_names() : array { + $componentnames = []; + // Get all plugins. + foreach (self::get_plugin_types() as $plugintype => $typedir) { + foreach (self::get_plugin_list($plugintype) as $pluginname => $plugindir) { + $componentnames[] = $plugintype . '_' . $pluginname; + } + } + // Get all subsystems. + foreach (self::get_core_subsystems() as $subsystemname => $subsystempath) { + $componentnames[] = 'core_' . $subsystemname; + } + return $componentnames; + } } diff --git a/lib/db/install.xml b/lib/db/install.xml index 455b29c7944..76c20f5bc39 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -3898,5 +3898,27 @@ + + + + + + + + + + + + + + + + + + + + + +
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index cd3dc8ef36a..6e42c336493 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2533,5 +2533,42 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2018101700.01); } + if ($oldversion < 2018101800.00) { + // Define table 'favourite' to be created. + $table = new xmldb_table('favourite'); + + // Adding fields to table 'favourite'. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('itemtype', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null); + $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('ordering', XMLDB_TYPE_INTEGER, 10, null, null, null, null); + $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + + // Adding keys to table 'favourite'. + $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); + $table->add_key('contextid', XMLDB_KEY_FOREIGN, array('contextid'), 'context', array('id')); + $table->add_key('userid', XMLDB_KEY_FOREIGN, array('userid'), 'user', array('id')); + + // Conditionally launch create table for 'favourite'. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Add composite index 'uniqueuserfavouriteitem' to the table 'favourite'. + $index = new xmldb_index('uniqueuserfavouriteitem', XMLDB_INDEX_UNIQUE, + ['component', 'itemtype', 'itemid', 'contextid', 'userid']); + + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2018101800.00); + } + return true; } diff --git a/lib/tests/component_test.php b/lib/tests/component_test.php index 3e3bd9640f2..2b79591082b 100644 --- a/lib/tests/component_test.php +++ b/lib/tests/component_test.php @@ -36,7 +36,7 @@ class core_component_testcase extends advanced_testcase { * this is defined here to annoy devs that try to add more without any thinking, * always verify that it does not collide with any existing add-on modules and subplugins!!! */ - const SUBSYSTEMCOUNT = 66; + const SUBSYSTEMCOUNT = 67; public function setUp() { $psr0namespaces = new ReflectionProperty('core_component', 'psr0namespaces'); @@ -805,4 +805,31 @@ class core_component_testcase extends advanced_testcase { $this->assertEquals($componentslist['mod']['mod_forum'], $CFG->dirroot . '/mod/forum'); $this->assertEquals($componentslist['tool']['tool_usertours'], $CFG->dirroot . '/' . $CFG->admin . '/tool/usertours'); } + + /** + * Test the get_component_names() method. + */ + public function test_get_component_names() { + global $CFG; + $componentnames = \core_component::get_component_names(); + + // We should have an entry for each plugin type. + $plugintypes = \core_component::get_plugin_types(); + $numplugintypes = 0; + foreach ($plugintypes as $type => $typedir) { + foreach (\core_component::get_plugin_list($type) as $plugin) { + $numplugintypes++; + } + } + // And an entry for each core subsystem. + $numcomponents = $numplugintypes + count(\core_component::get_core_subsystems()); + + $this->assertEquals($numcomponents, count($componentnames)); + + // Check a few of the known plugin types to confirm their presence at their respective type index. + $this->assertContains('core_comment', $componentnames); + $this->assertContains('mod_forum', $componentnames); + $this->assertContains('tool_usertours', $componentnames); + $this->assertContains('core_favourites', $componentnames); + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 8cc36edb6b3..60af5a0d630 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -47,6 +47,9 @@ lib/tests lib/ajax/tests + + favourites/tests + lib/form/tests diff --git a/version.php b/version.php index 6ed63147562..d6b3bc5ee30 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2018101700.01; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2018101800.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.