diff --git a/lang/en/xapi.php b/lang/en/xapi.php new file mode 100644 index 00000000000..5b998975ac0 --- /dev/null +++ b/lang/en/xapi.php @@ -0,0 +1,26 @@ +. + +/** + * Strings for xapi library, language 'en' + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['eventxapipost'] = 'Post xAPI statement'; +$string['privacy:metadata'] = 'The xAPI library does not store any personal data.'; diff --git a/lib/components.json b/lib/components.json index 1457de2486c..f1c2d8ac450 100644 --- a/lib/components.json +++ b/lib/components.json @@ -108,6 +108,7 @@ "timezones": null, "user": "user", "userkey": "lib\/userkey", - "webservice": "webservice" + "webservice": "webservice", + "xapi": "lib\/xapi" } } diff --git a/lib/db/services.php b/lib/db/services.php index 9442f9cb3b5..9590963a454 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -2744,6 +2744,16 @@ $functions = array( 'capabilities' => '', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], + 'core_xapi_statement_post' => [ + 'classname' => 'core_xapi\external\post_statement', + 'methodname' => 'execute', + 'classpath' => '', + 'description' => 'Post an xAPI statement.', + 'type' => 'write', + 'ajax' => 'true', + 'capabilities' => '', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], ); $services = array( diff --git a/lib/tests/component_test.php b/lib/tests/component_test.php index 11bca5ee1da..f7213121446 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 = 69; + const SUBSYSTEMCOUNT = 70; public function setUp() { $psr0namespaces = new ReflectionProperty('core_component', 'psr0namespaces'); diff --git a/lib/xapi/classes/external/post_statement.php b/lib/xapi/classes/external/post_statement.php new file mode 100644 index 00000000000..95118a52338 --- /dev/null +++ b/lib/xapi/classes/external/post_statement.php @@ -0,0 +1,178 @@ +. + +/** + * This is the external API for generic xAPI handling. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\external; + +use core_xapi\local\statement; +use core_xapi\handler; +use core_xapi\xapi_exception; +use external_api; +use external_function_parameters; +use external_value; +use external_single_structure; +use external_multiple_structure; +use external_warnings; +use core_component; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir .'/externallib.php'); + +/** + * This is the external API for generic xAPI handling. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class post_statement extends external_api { + + /** + * Parameters for execute + * + * @return external_function_parameters + */ + public static function execute_parameters() { + return new external_function_parameters( + [ + 'component' => new external_value(PARAM_COMPONENT, 'Component name', VALUE_REQUIRED), + 'requestjson' => new external_value(PARAM_RAW, 'json object with all the statements to post', VALUE_REQUIRED) + ] + ); + } + + /** + * Process a statement post request. + * + * @param string $component component name (frankenstyle) + * @param string $requestjson json object with all the statements to post + * @return bool[] storing acceptance of every statement + */ + public static function execute(string $component, string $requestjson): array { + + $params = self::validate_parameters(self::execute_parameters(), array( + 'component' => $component, + 'requestjson' => $requestjson, + )); + $component = $params['component']; + $requestjson = $params['requestjson']; + + static::validate_component($component); + + $handler = handler::create($component); + + $statements = self::get_statements_from_json($requestjson); + + if (!self::check_statements_users($statements, $handler)) { + throw new xapi_exception('Statements actor is not the current user'); + } + + $result = $handler->process_statements($statements); + + // In case no statement is processed, an error must be returned. + if (count(array_filter($result)) == 0) { + throw new xapi_exception('No statement can be processed.'); + } + return $result; + } + + /** + * Return for execute. + */ + public static function execute_returns() { + return new external_multiple_structure( + new external_value(PARAM_BOOL, 'If the statement is accepted'), + 'List of statements storing acceptance results' + ); + } + + /** + * Check component name. + * + * Note: this function is separated mainly for testing purposes to + * be overridden to fake components. + * + * @throws xapi_exception if component is not available + * @param string $component component name + */ + protected static function validate_component(string $component): void { + // Check that $component is a real component name. + $dir = core_component::get_component_directory($component); + if (!$dir) { + throw new xapi_exception("Component $component not available."); + } + } + + /** + * Convert mulitple types of statement request into an array of statements. + * + * @throws xapi_exception if JSON cannot be parsed + * @param string $requestjson json encoded statements structure + * @return statement[] array of statements + */ + private static function get_statements_from_json(string $requestjson): array { + $request = json_decode($requestjson); + if ($request === null) { + throw new xapi_exception('JSON error: '.json_last_error_msg()); + } + $result = []; + if (is_array($request)) { + foreach ($request as $data) { + $result[] = statement::create_from_data($data); + } + } else { + $result[] = statement::create_from_data($request); + } + if (empty($result)) { + throw new xapi_exception('No statements detected'); + } + return $result; + } + + /** + * Check that $USER is actor in all statements. + * + * @param statement[] $statements array of statements + * @param handler $handler specific xAPI handler + * @return bool if $USER is actor in all statements + */ + private static function check_statements_users(array $statements, handler $handler): bool { + global $USER; + + foreach ($statements as $statement) { + if ($handler->supports_group_actors()) { + $users = $statement->get_all_users(); + if (!isset($users[$USER->id])) { + return false; + } + } else { + $user = $statement->get_user(); + if ($user->id != $USER->id) { + return false; + } + } + } + return true; + } +} diff --git a/lib/xapi/classes/handler.php b/lib/xapi/classes/handler.php new file mode 100644 index 00000000000..8c0b2c457b2 --- /dev/null +++ b/lib/xapi/classes/handler.php @@ -0,0 +1,118 @@ +. + +/** + * The core_xapi statement validation and tansformation. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi; + +use core_xapi\local\statement; +use core_xapi\xapi_exception; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class handler handles basic xapi statements. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + */ +abstract class handler { + + /** @var string component name in frankenstyle. */ + protected $component; + + /** + * Constructor for a xAPI handler base class. + * + * @param string $component the component name + */ + final protected function __construct(string $component) { + $this->component = $component; + } + + /** + * Returns the xAPI handler of a specific component. + * + * @param string $component the component name in frankenstyle. + * @return handler|null a handler object or null if none found. + * @throws xapi_exception + */ + final public static function create(string $component): self { + $classname = "\\$component\\xapi\\handler"; + if (class_exists($classname)) { + return new $classname($component); + } + throw new xapi_exception('Unknown handler'); + } + + /** + * Convert a statement object into a Moodle xAPI Event. + * + * If a statement is accepted by validate_statement the component must provide a event + * to handle that statement, otherwise the statement will be rejected. + * + * Note: this method must be overridden by the plugins which want to use xAPI. + * + * @param statement $statement + * @return \core\event\base|null a Moodle event to trigger + */ + abstract public function statement_to_event(statement $statement): ?\core\event\base; + + /** + * Return true if group actor is enabled. + * + * Note: this method must be overridden by the plugins which want to + * use groups in statements. + * + * @return bool + */ + public function supports_group_actors(): bool { + return false; + } + + /** + * Process a bunch of statements sended to a specific component. + * + * @param statement[] $statements an array with all statement to process. + * @return int[] return an specifying what statements are being stored. + */ + public function process_statements(array $statements): array { + $result = []; + foreach ($statements as $key => $statement) { + try { + // Ask the plugin to convert into an event. + $event = $this->statement_to_event($statement); + if ($event) { + $event->trigger(); + $result[$key] = true; + } else { + $result[$key] = false; + } + } catch (\Exception $e) { + $result[$key] = false; + } + } + return $result; + } +} diff --git a/lib/xapi/classes/iri.php b/lib/xapi/classes/iri.php new file mode 100644 index 00000000000..c635d0717fe --- /dev/null +++ b/lib/xapi/classes/iri.php @@ -0,0 +1,93 @@ +. + +/** + * xAPI LRS IRI values generator. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_xapi; +defined('MOODLE_INTERNAL') || die(); + +use stdClass; +use moodle_url; + +/** + * Class to translate Moodle objects to xAPI elements. + * + * @copyright 2020 Ferran Recio + * @since Moodle 3.9 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class iri { + + /** + * Generate a valid IRI element from a $value and an optional $type. + * + * Verbs and Objects in xAPI are in IRI format. This function could get + * a valid IRI value (and will return without modifiyng it) or a simple + * string and a type and generate a fake IRI valir for any xAPI statement. + * + * @param string $value a valid IRI value or any string + * @param string|null $type if none passed $type will be 'element' + * @return string a valid IRI value + */ + public static function generate(string $value, string $type = null): string { + if (self::check($value)) { + return $value; + } + if (empty($type)) { + $type = 'element'; + } + return (new moodle_url("/xapi/$type/$value"))->out(false); + } + + /** + * Try to extract the original value from an IRI. + * + * If a real IRI value is passed, it will return it without any change. If a + * fake IRI is passed (generated by iri::generate) + * it will try to extract the original value. + * + * @param string $value the currewnt IRI value. + * @param string|null $type if $value is a fake IRI, the $type must be provided. + * @return string the original value used in iri::generate. + */ + public static function extract(string $value, string $type = null): string { + if (empty($type)) { + $type = 'element'; + } + $xapibase = (new moodle_url("/xapi/$type/"))->out(false); + if (strpos($value, $xapibase) === 0) { + return substr($value, strlen($xapibase)); + } + return $value; + } + + /** + * Check if a $value could be a valid IRI or not. + * + * @param string $value the currewnt IRI value. + * @return bool if the $value could be an IRI. + */ + public static function check(string $value): bool { + $iri = new moodle_url($value); + return in_array($iri->get_scheme(), ['http', 'https']); + } +} diff --git a/lib/xapi/classes/local/statement.php b/lib/xapi/classes/local/statement.php new file mode 100644 index 00000000000..7314d4b8c3c --- /dev/null +++ b/lib/xapi/classes/local/statement.php @@ -0,0 +1,401 @@ +. + +/** + * Statement base object for xAPI structure checking and validation. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local; + +use core_xapi\local\statement\item; +use core_xapi\local\statement\item_actor; +use core_xapi\local\statement\item_object; +use core_xapi\local\statement\item_verb; +use core_xapi\xapi_exception; +use JsonSerializable; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for core_xapi implementing null_provider. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class statement implements JsonSerializable { + + /** @var actor The statement actor. */ + protected $actor = null; + + /** @var verb The statement verb. */ + protected $verb = null; + + /** @var object The statement object. */ + protected $object = null; + + /** @var result The statement result. */ + protected $result = null; + + /** @var context The statement context. */ + protected $context = null; + + /** @var timestamp The statement timestamp. */ + protected $timestamp = null; + + /** @var stored The statement stored. */ + protected $stored = null; + + /** @var authority The statement authority. */ + protected $authority = null; + + /** @var version The statement version. */ + protected $version = null; + + /** @var attachments The statement attachments. */ + protected $attachments = null; + + /** @var additionalfields list of additional fields. */ + private static $additionalsfields = [ + 'context', 'result', 'timestamp', 'stored', 'authority', 'version', 'attachments' + ]; + + /** + * Function to create a full statement from xAPI statement data. + * + * @param stdClass $data the original xAPI statement + * @return statement statement object + */ + public static function create_from_data(stdClass $data): self { + + $result = new self(); + + $requiredfields = ['actor', 'verb', 'object']; + foreach ($requiredfields as $required) { + if (!isset($data->$required)) { + throw new xapi_exception("Missing '{$required}'"); + } + } + $result->set_actor(item_actor::create_from_data($data->actor)); + $result->set_verb(item_verb::create_from_data($data->verb)); + $result->set_object(item_object::create_from_data($data->object)); + + // Store other generic xAPI statement fields. + foreach (self::$additionalsfields as $additional) { + if (isset($data->$additional)) { + $method = 'set_'.$additional; + $result->$method(item::create_from_data($data->$additional)); + } + } + return $result; + } + + /** + * Return the data to serialize in case JSON statement is needed. + * + * @return stdClass the statement data structure + */ + public function jsonSerialize(): stdClass { + $result = (object) [ + 'actor' => $this->actor, + 'verb' => $this->verb, + 'object' => $this->object, + ]; + foreach (self::$additionalsfields as $additional) { + if (!empty($this->$additional)) { + $result->$additional = $this->$additional; + } + } + return $result; + } + + /** + * Returns a minified version of a given statement. + * + * The returned structure is suitable to store in the "other" field + * of logstore. xAPI standard specifies a list of attributes that can be calculated + * instead of stored literally. This function get rid of these attributes. + * + * Note: it also converts stdClass to assoc array to make it compatible + * with "other" field in the logstore + * + * @return array the minimal statement needed to be stored a part from logstore data + */ + public function minify(): ?array { + $result = []; + $fields = ['verb', 'object', 'context', 'result', 'authority', 'attachments']; + foreach ($fields as $field) { + if (!empty($this->$field)) { + $result[$field] = $this->$field; + } + } + return json_decode(json_encode($result), true); + } + + /** + * Set the statement actor. + * + * @param item_actor $actor actor item + */ + public function set_actor(item_actor $actor): void { + $this->actor = $actor; + } + + /** + * Set the statement verb. + * + * @param item_verb $verb verb element + */ + public function set_verb(item_verb $verb): void { + $this->verb = $verb; + } + + /** + * Set the statement object. + * + * @param item_object $object compatible object item + */ + public function set_object(item_object $object): void { + $this->object = $object; + } + + /** + * Set the statement context. + * + * @param item $context context item element + */ + public function set_context(item $context): void { + $this->context = $context; + } + + /** + * Set the statement result. + * + * @param item $result result item element + */ + public function set_result(item $result): void { + $this->result = $result; + } + + /** + * Set the statement timestamp. + * + * @param item $timestamp timestamp item element + */ + public function set_timestamp(item $timestamp): void { + $this->timestamp = $timestamp; + } + + /** + * Set the statement stored. + * + * @param item $stored stored item element + */ + public function set_stored(item $stored): void { + $this->stored = $stored; + } + + /** + * Set the statement authority. + * + * @param item $authority authority item element + */ + public function set_authority(item $authority): void { + $this->authority = $authority; + } + + /** + * Set the statement version. + * + * @param item $version version item element + */ + public function set_version(item $version): void { + $this->version = $version; + } + + /** + * Set the statement attachments. + * + * @param item $attachments attachments item element + */ + public function set_attachments(item $attachments): void { + $this->attachments = $attachments; + } + + /** + * Returns the moodle user represented by this statement actor. + * + * @throws xapi_exception if it's a group statement + * @return stdClass user record + */ + public function get_user(): stdClass { + if (!$this->actor) { + throw new xapi_exception("No actor defined"); + } + return $this->actor->get_user(); + } + + /** + * Return all moodle users represented by this statement actor. + * + * @return array user records + */ + public function get_all_users(): array { + if (!$this->actor) { + throw new xapi_exception("No actor defined"); + } + return $this->actor->get_all_users(); + } + + /** + * Return the moodle group represented by this statement actor. + * + * @throws xapi_exception if it is not a group statement + * @return stdClass a group record + */ + public function get_group(): stdClass { + if (!$this->actor) { + throw new xapi_exception("No actor defined"); + } + if (method_exists($this->actor, 'get_group')) { + return $this->actor->get_group(); + } + throw new xapi_exception("Method not valid on this actor"); + } + + /** + * Returns the statement verb ID. + * + * @throws xapi_exception in case the item is no yet defined + * @return string verb ID + */ + public function get_verb_id(): string { + if (!$this->verb) { + throw new xapi_exception("No verb defined"); + } + return $this->verb->get_id(); + } + + /** + * Returns the statement activity ID. + * + * @throws xapi_exception in case the item is no yet defined + * @return string activity ID + */ + public function get_activity_id(): string { + if (!$this->object) { + throw new xapi_exception("No object defined"); + } + if (method_exists($this->object, 'get_id')) { + return $this->object->get_id(); + } + throw new xapi_exception("Method not valid on this object"); + } + + /** + * Return the statement actor if it is defined. + * + * @return item_actor|null + */ + public function get_actor(): ?item_actor { + return $this->actor; + } + + /** + * Return the statement verb if it is defined. + * + * @return item_verb|null + */ + public function get_verb(): ?item_verb { + return $this->verb; + } + + /** + * Return the statement object if it is defined. + * + * @return item_object|null + */ + public function get_object(): ?item_object { + return $this->object; + } + + /** + * Return the statement context if it is defined. + * + * @return item|null + */ + public function get_context(): ?item { + return $this->context; + } + + /** + * Return the statement result if it is defined. + * + * @return item|null + */ + public function get_result(): ?item { + return $this->result; + } + + /** + * Return the statement timestamp if it is defined. + * + * @return item|null + */ + public function get_timestamp(): ?item { + return $this->timestamp; + } + + /** + * Return the statement stored if it is defined. + * + * @return item|null + */ + public function get_stored(): ?item { + return $this->stored; + } + + /** + * Return the statement authority if it is defined. + * + * @return item|null + */ + public function get_authority(): ?item { + return $this->authority; + } + + /** + * Return the statement version if it is defined. + * + * @return item|null + */ + public function get_version(): ?item { + return $this->version; + } + + /** + * Return the statement attachments if it is defined. + * + * @return item|null + */ + public function get_attachments(): ?item { + return $this->attachments; + } +} diff --git a/lib/xapi/classes/local/statement/item.php b/lib/xapi/classes/local/statement/item.php new file mode 100644 index 00000000000..f1057b60001 --- /dev/null +++ b/lib/xapi/classes/local/statement/item.php @@ -0,0 +1,79 @@ +. + +/** + * Statement base object for xAPI structure checking and usage. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; +use stdClass; +use JsonSerializable; +use core_xapi\iri; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Item class used for xAPI statement elements without validation. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item implements JsonSerializable { + + /** @var stdClass the item structure. */ + protected $data; + + /** + * Item constructor. + * + * @param stdClass $data from the specific xAPI element + */ + protected function __construct(stdClass $data) { + $this->data = $data; + } + + /** + * Function to create an item from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @return item the xAPI item generated + */ + public static function create_from_data(stdClass $data): item { + return new self($data); + } + + /** + * Return the data to serialize in case JSON statement is needed. + * + * @return stdClass the original data structure + */ + public function jsonSerialize(): stdClass { + return $this->get_data(); + } + + /** + * Return the original data from this item. + * + * @return stdClass the original data structure + */ + public function get_data(): stdClass { + return $this->data; + } +} diff --git a/lib/xapi/classes/local/statement/item_activity.php b/lib/xapi/classes/local/statement/item_activity.php new file mode 100644 index 00000000000..2ee8c1a8e4e --- /dev/null +++ b/lib/xapi/classes/local/statement/item_activity.php @@ -0,0 +1,129 @@ +. + +/** + * Statement activity object for xAPI structure checking and usage. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use core_xapi\xapi_exception; +use core_xapi\iri; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class that implements a xAPI activity compatible with xAPI object. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_activity extends item_object { + + /** @var string Activity ID. */ + protected $id; + + /** @var item_definition Definition object. */ + protected $definition; + + /** + * Item activity constructor. + * + * An xAPI activity is mainly an IRI ID and an optional definition. + * + * @param stdClass $data from the specific xAPI element + * @param item_definition $definition option definition item + */ + protected function __construct(stdClass $data, item_definition $definition = null) { + parent::__construct($data); + $this->id = iri::extract($data->id, 'activity'); + $this->definition = $definition; + } + + /** + * Function to create an item from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @return item item_activity xAPI generated + */ + public static function create_from_data(stdClass $data): item { + if (!isset($data->objectType)) { + throw new xapi_exception('Missing activity objectType'); + } + if ($data->objectType != 'Activity') { + throw new xapi_exception('Activity objectType must be "Activity"'); + } + if (empty($data->id)) { + throw new xapi_exception("Missing Activity id"); + } + if (!iri::check($data->id)) { + throw new xapi_exception("Activity id $data->id is not a valid IRI"); + } + + $definition = null; + if (!empty($data->definition)) { + $definition = item_definition::create_from_data($data->definition); + } + + return new self($data, $definition); + } + + /** + * Generate a valid item_activity from a simple ID string and an optional definition. + * + * @param string $id any string that will converted into a valid IRI + * @param item_definition|null $definition optional item_definition + * @return item_activity + */ + public static function create_from_id(string $id, item_definition $definition = null): item_activity { + $data = (object) [ + 'objectType' => 'Activity', + 'id' => iri::generate($id, 'activity'), + ]; + + if (!empty($definition)) { + $data->definition = $definition->get_data(); + } + + return new self($data, $definition); + } + + /** + * Return the activity ID. + * + * If the ID was generated by iri::generate this function will return + * the iri:extract value. + * + * @return string the activity ID + */ + public function get_id(): string { + return $this->id; + } + + /** + * Returns the item_definition of this item. + * + * @return item_definition|null the item definition if available + */ + public function get_definition(): ?item_definition { + return $this->definition; + } +} diff --git a/lib/xapi/classes/local/statement/item_actor.php b/lib/xapi/classes/local/statement/item_actor.php new file mode 100644 index 00000000000..002d70db807 --- /dev/null +++ b/lib/xapi/classes/local/statement/item_actor.php @@ -0,0 +1,79 @@ +. + +/** + * Statement actor (user or group) object for xAPI structure checking and usage. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use core_xapi\xapi_exception; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Abstract xAPI actor class. + * + * This class extends from item_object instead of basic item + * because both actors (agent and group) could be used as + * statement actor or object. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class item_actor extends item_object { + + /** + * Function to create an actor from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @return item item_agent|item_grou|item_activity xAPI generated + */ + public static function create_from_data(stdClass $data): item { + if (!isset($data->objectType)) { + $data->objectType = 'Agent'; + } + switch ($data->objectType) { + case 'Agent': + return item_agent::create_from_data($data); + break; + case 'Group': + return item_group::create_from_data($data); + break; + default: + throw new xapi_exception("Unknown Actor type '{$data->objectType}'"); + } + } + + /** + * Returns the moodle user represented by this item. + * + * @return stdClass user record + */ + abstract public function get_user(): stdClass; + + /** + * Return all moodle users represented by this item. + * + * @return array user records + */ + abstract public function get_all_users(): array; +} diff --git a/lib/xapi/classes/local/statement/item_agent.php b/lib/xapi/classes/local/statement/item_agent.php new file mode 100644 index 00000000000..6ac2135a05b --- /dev/null +++ b/lib/xapi/classes/local/statement/item_agent.php @@ -0,0 +1,142 @@ +. + +/** + * Statement agent (user) object for xAPI structure checking and usage. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use core_xapi\xapi_exception; +use core_user; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Agent xAPI statement element representing a Moodle user. + * + * Agents can be used either as actor or object in a statement. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_agent extends item_actor { + + /** @var stdClass The user record of this actor. */ + protected $user; + + /** + * Function to create an agent (user) from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @param stdClass $user user record + */ + protected function __construct(stdClass $data, stdClass $user) { + parent::__construct($data); + $this->user = $user; + } + + /** + * Function to create an item from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @return item item_agentxAPI generated + */ + public static function create_from_data(stdClass $data): item { + global $CFG; + if (!isset($data->objectType)) { + throw new xapi_exception('Missing agent objectType'); + } + if ($data->objectType != 'Agent') { + throw new xapi_exception("Agent objectType must be 'Agent'"); + } + if (isset($data->account) && isset($data->mbox)) { + throw new xapi_exception("Agent cannot have more than one identifier"); + } + $user = null; + if (!empty($data->account)) { + if ($data->account->homePage != $CFG->wwwroot) { + throw new xapi_exception("Invalid agent homePage '{$data->account->homePage}'"); + } + if (!is_numeric($data->account->name)) { + throw new xapi_exception("Agent account name must be integer '{$data->account->name}' found"); + } + $user = core_user::get_user($data->account->name); + if (empty($user)) { + throw new xapi_exception("Inexistent agent '{$data->account->name}'"); + } + } + if (!empty($data->mbox)) { + $mbox = str_replace('mailto:', '', $data->mbox); + $user = core_user::get_user_by_email($mbox); + if (empty($user)) { + throw new xapi_exception("Inexistent agent '{$data->mbox}'"); + } + } + if (empty($user)) { + throw new xapi_exception("Unsupported agent definition"); + } + return new self($data, $user); + } + + /** + * Create a item_agent from a existing user. + * + * @param stdClass $user A user record. + * @return item_agent + */ + public static function create_from_user(stdClass $user): item_agent { + global $CFG; + + if (!isset($user->id)) { + throw new xapi_exception("Missing user id"); + } + $data = (object) [ + 'objectType' => 'Agent', + 'account' => (object) [ + 'homePage' => $CFG->wwwroot, + 'name' => $user->id, + ], + ]; + return new self($data, $user); + } + + /** + * Returns the moodle user represented by this item. + * + * @return stdClass user record + */ + public function get_user(): stdClass { + return $this->user; + } + + /** + * Return all users represented by this item. + * + * In this case the item is an agent so a single element array + * will be returned always. + * + * @return array list of users + */ + public function get_all_users(): array { + return [$this->user->id => $this->user]; + } +} diff --git a/lib/xapi/classes/local/statement/item_definition.php b/lib/xapi/classes/local/statement/item_definition.php new file mode 100644 index 00000000000..e1c080eaaa0 --- /dev/null +++ b/lib/xapi/classes/local/statement/item_definition.php @@ -0,0 +1,93 @@ +. + +/** + * Statement definition object for xAPI structure checking and usage. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use core_xapi\xapi_exception; +use core_xapi\iri; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Validation and usage of xAPI definition. + * + * Definition contains extra information about user interaction with + * questions and other activities inside a xAPI statement. For now + * it performs a basic validation on the provided data. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_definition extends item { + + /** @var string The statement. */ + protected $interactiontype; + + /** + * Function to create a definition from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element. + */ + protected function __construct(stdClass $data) { + parent::__construct($data); + $this->interactiontype = $data->interactionType ?? null; + } + + /** + * Function to create an item from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @return item item_definition xAPI generated + */ + public static function create_from_data(stdClass $data): item { + // Interaction Type is a optopnal param. + if (!empty($data->interactionType)) { + $posiblevalues = [ + 'choice' => true, + 'fill-in' => true, + 'long-fill-in' => true, + 'true-false' => true, + 'matching' => true, + 'performance' => true, + 'sequencing' => true, + 'likert' => true, + 'numeric' => true, + 'other' => true, + 'compound' => true, + ]; + if (!isset($posiblevalues[$data->interactionType])) { + throw new xapi_exception("Invalid definition \"{$data->interactionType}\""); + } + } + return new self($data); + } + + /** + * Return the definition interaction type. + */ + public function get_interactiontype(): ?string { + return $this->interactiontype; + } +} diff --git a/lib/xapi/classes/local/statement/item_group.php b/lib/xapi/classes/local/statement/item_group.php new file mode 100644 index 00000000000..1773793ceb5 --- /dev/null +++ b/lib/xapi/classes/local/statement/item_group.php @@ -0,0 +1,147 @@ +. + +/** + * Statement group object for xAPI structure checking and usage. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use core_xapi\xapi_exception; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Group item inside a xAPI statement. + * + * Only named groups are accepted (all groups must be real groups in the + * platform) so anonymous groups will be rejected on creation. Groups can + * be used as actor or as object inside a xAPI statement. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_group extends item_actor { + + /** @var timestamp The statement timestamp. */ + protected $users; + + /** @var timestamp The statement timestamp. */ + protected $group; + + /** + * Function to create an group from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @param stdClass $group group record + */ + protected function __construct(stdClass $data, stdClass $group) { + parent::__construct($data); + $this->group = $group; + $this->users = groups_get_members($group->id); + if (!$this->users) { + $this->users = []; + } + } + + /** + * Function to create an item from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @return item item_group xAPI item generated + */ + public static function create_from_data(stdClass $data): item { + global $CFG; + if (!isset($data->objectType)) { + throw new xapi_exception('Missing group objectType'); + } + if ($data->objectType != 'Group') { + throw new xapi_exception("Group objectType must be 'Group'"); + } + if (!isset($data->account)) { + throw new xapi_exception("Missing Group account"); + } + if ($data->account->homePage != $CFG->wwwroot) { + throw new xapi_exception("Invalid group homePage '{$data->account->homePage}'"); + } + if (!is_numeric($data->account->name)) { + throw new xapi_exception("Agent account name must be integer '{$data->account->name}' found"); + } + $group = groups_get_group($data->account->name); + if (empty($group)) { + throw new xapi_exception("Inexistent group '{$data->account->name}'"); + } + return new self($data, $group); + } + + /** + * Create a item_group from a existing group. + * + * @param stdClass $group A group record. + * @return item_group + */ + public static function create_from_group(stdClass $group): item_group { + global $CFG; + + if (!isset($group->id)) { + throw new xapi_exception("Missing group id"); + } + $data = (object) [ + 'objectType' => 'Group', + 'account' => (object) [ + 'homePage' => $CFG->wwwroot, + 'name' => $group->id, + ], + ]; + return new self($data, $group); + } + + /** + * Returns the moodle user represented by this item. + * + * This is a group item. To avoid security problems this method + * thorws an exception when is called from a item_group class. + * + * @throws xapi_exception get_user must not be called from an item_group + * @return stdClass user record + */ + public function get_user(): stdClass { + throw new xapi_exception("Group statements cannot be used as a individual user"); + } + + /** + * Return all users from the group represented by this item. + * + * @return array group users + */ + public function get_all_users(): array { + return $this->users; + } + + /** + * Return the moodle group represented by this item. + * + * @return stdClass a group record + */ + public function get_group(): stdClass { + return $this->group; + } +} diff --git a/lib/xapi/classes/local/statement/item_object.php b/lib/xapi/classes/local/statement/item_object.php new file mode 100644 index 00000000000..c51f4502b60 --- /dev/null +++ b/lib/xapi/classes/local/statement/item_object.php @@ -0,0 +1,69 @@ +. + +/** + * Statement object (activity, user or group) for xAPI structure checking and usage. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use core_xapi\xapi_exception; +use core_xapi\iri; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Abstract object item used in xAPI statements. + * + * Object represents the object in which a xAPI verb is applied. There + * are 3 types of objects supported: agent (user), group (of users) and + * activity (defined by every plugin). + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class item_object extends item { + + /** + * Create a xAPI object compatible from data (Agent, Group or Activity). + * + * @param stdClass $data data structure from statement object + * @return item item_group|item_agent|item_activity resulting object + */ + public static function create_from_data(stdClass $data): item { + if (!isset($data->objectType)) { + $data->objectType = 'Activity'; + } + switch ($data->objectType) { + case 'Agent': + return item_agent::create_from_data($data); + break; + case 'Group': + return item_group::create_from_data($data); + break; + case 'Activity': + return item_activity::create_from_data($data); + break; + default: + throw new xapi_exception("Unknown Object type '{$data->objectType}'"); + } + } +} diff --git a/lib/xapi/classes/local/statement/item_verb.php b/lib/xapi/classes/local/statement/item_verb.php new file mode 100644 index 00000000000..876c168cc9b --- /dev/null +++ b/lib/xapi/classes/local/statement/item_verb.php @@ -0,0 +1,103 @@ +. + +/** + * Statement verb object for xAPI structure checking and usage. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use core_xapi\xapi_exception; +use core_xapi\iri; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Verb xAPI statement item. + * + * Verbs represent the interaction a user/group made inside a xAPI + * compatible plugin. Internally a xAPI verb must be representad as + * in a valid IRI format (which is a less restrictive version of a + * regular URL so a moodle_url out is completelly fine). To make it + * easy for plugins to generate valid IRI, a simple string van be + * provided and the class will convert into a valid IRI internally. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_verb extends item { + + /** @var string The statement. */ + protected $id; + + /** + * An xAPI verb constructor based on xAPI data structure. + * + * @param stdClass $data from the specific xAPI element + */ + protected function __construct(stdClass $data) { + parent::__construct($data); + $this->id = iri::extract($data->id, 'verb'); + } + + /** + * Function to create an item from part of the xAPI statement. + * + * @param stdClass $data the original xAPI element + * @return item item_verb xAPI generated + */ + public static function create_from_data(stdClass $data): item { + if (empty($data->id)) { + throw new xapi_exception("missing verb id"); + } + if (!iri::check($data->id)) { + throw new xapi_exception("verb id $data->id is not a valid IRI"); + } + return new self($data); + } + + /** + * Create a valid item_verb from a simple verb string. + * + * @param string $id string to convert to a valid IRI (or a valid IRI) + * @return item_verb the resulting item_verb + */ + public static function create_from_id(string $id): item_verb { + + $data = new stdClass(); + $data->id = iri::generate($id, 'verb'); + + return new self($data); + } + + /** + * Return the id used in this item. + * + * Id will be extracted from the provided IRI. If it's a valid IRI + * it will return all IRI value but if it is generate by the iri helper + * from this library it will extract the original value. + * + * @return string the ID (extracted from IRI value) + */ + public function get_id(): string { + return $this->id; + } +} diff --git a/lib/xapi/classes/privacy/provider.php b/lib/xapi/classes/privacy/provider.php new file mode 100644 index 00000000000..b74a581f41b --- /dev/null +++ b/lib/xapi/classes/privacy/provider.php @@ -0,0 +1,46 @@ +. + +/** + * Privacy Subsystem implementation for core xAPI Library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\privacy; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy Subsystem for core_xapi implementing null_provider. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/lib/xapi/classes/xapi_exception.php b/lib/xapi/classes/xapi_exception.php new file mode 100644 index 00000000000..e02ad746c8d --- /dev/null +++ b/lib/xapi/classes/xapi_exception.php @@ -0,0 +1,37 @@ +. + +/** + * General xAPI invalid statement exception. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi; + +defined('MOODLE_INTERNAL') || die(); + +/** + * General invalid xAPI exception. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class xapi_exception extends \moodle_exception { +} diff --git a/lib/xapi/tests/coverage.php b/lib/xapi/tests/coverage.php new file mode 100644 index 00000000000..d0a81abdc9b --- /dev/null +++ b/lib/xapi/tests/coverage.php @@ -0,0 +1,48 @@ +. + +/** + * Coverage information for the core_xapi component. + * + * @package core_xapi + * @category test + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Coverage information for the core xAPI component. + * + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +return new class extends phpunit_coverage_info { + /** @var array The list of folders relative to the plugin root to whitelist in coverage generation. */ + protected $whitelistfolders = [ + 'classes', + ]; + + /** @var array The list of files relative to the plugin root to whitelist in coverage generation. */ + protected $whitelistfiles = []; + + /** @var array The list of folders relative to the plugin root to excludelist in coverage generation. */ + protected $excludelistfolders = []; + + /** @var array The list of files relative to the plugin root to excludelist in coverage generation. */ + protected $excludelistfiles = []; +}; diff --git a/lib/xapi/tests/external/post_statement_test.php b/lib/xapi/tests/external/post_statement_test.php new file mode 100644 index 00000000000..90e5000e64a --- /dev/null +++ b/lib/xapi/tests/external/post_statement_test.php @@ -0,0 +1,490 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_xapi\external; + +use core_xapi\xapi_exception; +use core_xapi\test_helper; +use core_xapi\external\post_statement; +use core_xapi\local\statement; +use core_xapi\local\statement\item_agent; +use core_xapi\local\statement\item_group; +use core_xapi\local\statement\item_verb; +use core_xapi\local\statement\item_activity; +use externallib_advanced_testcase; +use stdClass; +use external_api; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); + +/** + * Unit tests for xAPI statement processing webservice. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class post_statement_testcase extends externallib_advanced_testcase { + + /** @var test_helper for generating valid xapi statements. */ + private $testhelper; + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setupBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot.'/lib/xapi/tests/helper.php'); + } + + /** + * Setup test. + */ + public function setUp() { + global $CFG; + // We disable group actors on the test xapi_handler. + $CFG->xapitestforcegroupactors = false; + } + + /** + * Return a xAPI external webservice class to operate. + * + * The test needs to fake a component in order to test without + * using a real one. This way if in the future any component + * implement it's xAPI handler this test will continue working. + * + * @return post_statement the external class + */ + private function get_extenal_class(): post_statement { + $ws = new class extends post_statement { + protected static function validate_component(string $component): void { + if ($component != 'fake_component') { + parent::validate_component($component); + } + } + }; + return $ws; + } + + /** + * This function do all checks from a standard post_statements request. + * + * The reason for this function is because statements crafting (special in error + * scenarios) is complicated to do via data providers because every test need a specific + * testing conditions. For this reason alls tests creates a scenario and then uses this + * function to check the results. + * + * @param string $component component name + * @param mixed $data data to encode and send to post_statement + * @param array $expected expected results (i empty an exception is expected) + */ + private function post_statements_data(string $component, $data, array $expected) { + global $USER; + + $testhelper = new test_helper(); + $testhelper->init_log(); + + // If no result is expected we will just incur in exception. + if (empty($expected)) { + $this->expectException(xapi_exception::class); + } else { + $this->preventResetByRollback(); // Logging waits till the transaction gets committed. + } + + $json = json_encode($data); + + $external = $this->get_extenal_class(); + $result = $external::execute($component, $json); + $result = external_api::clean_returnvalue($external::execute_returns(), $result); + + // Check results. + $this->assertCount(count($expected), $result); + foreach ($expected as $key => $expect) { + $this->assertEquals($expect, $result[$key]); + } + + // Check log entries. + $log = $testhelper->get_last_log_entry(); + $this->assertNotEmpty($log); + + // Validate statement information on log. + $value = $log->get_name(); + $this->assertEquals($value, 'xAPI test statement'); + $value = $log->get_description(); + // Due to logstore limitation, event must use a real component (core_xapi). + $this->assertEquals($value, "User '{$USER->id}' send a statement to component 'core_xapi'"); + } + + /** + * Return a valid statement object with the params passed. + * + * All tests are based on craft different types os statements. This function + * is made to provent redundant code on the test. + * + * @param array $items array of overriden statement items (default []) + * @return statement the resulting statement + */ + private function get_valid_statement(array $items = []): statement { + global $USER; + + $actor = $items['actor'] ?? item_agent::create_from_user($USER); + $verb = $items['verb'] ?? item_verb::create_from_id('cook'); + $object = $items['object'] ?? item_activity::create_from_id('paella'); + + $statement = new statement(); + $statement->set_actor($actor); + $statement->set_verb($verb); + $statement->set_object($object); + + return $statement; + } + + /** + * Testing different component names on valid statements. + * + * @dataProvider components_provider + * @param string $component component name + * @param array $expected expected results + */ + public function test_component_names(string $component, array $expected) { + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Perform test. + $data = $this->get_valid_statement(); + $this->post_statements_data ($component, $data, $expected); + } + + /** + * Data provider for the test_component_names tests. + * + * @return array + */ + public function components_provider() : array { + return [ + 'Inexistent component' => [ + 'inexistent_component', [] + ], + 'Compatible component' => [ + 'fake_component', [true] + ], + 'Incompatible component' => [ + 'core_xapi', [] + ], + ]; + } + + /** + * Testing raw JSON encoding. + * + * This test is used for wrong json format and empty structures. + * + * @dataProvider invalid_json_provider + * @param string $json json string to send + */ + public function test_invalid_json(string $json) { + + $this->resetAfterTest(); + + // Scenario. + $this->setAdminUser(); + + // Perform test. + $testhelper = new test_helper(); + $testhelper->init_log(); + + // If no result is expected we will just incur in exception. + $this->expectException(xapi_exception::class); + + $external = $this->get_extenal_class(); + $result = $external::execute('fake_component', $json); + $result = external_api::clean_returnvalue($external::execute_returns(), $result); + } + + /** + * Data provider for the test_components tests. + * + * @return array + */ + public function invalid_json_provider() : array { + return [ + 'Wrong json' => [ + 'This is not { a json object /' + ], + 'Empty string json' => [ + '' + ], + 'Empty array json' => [ + '[]' + ], + 'Invalid single statement json' => [ + '{"actor":{"objectType":"Agent","mbox":"noemail@moodle.org"},"verb":{"id":"InvalidVerb"}' + .',"object":{"objectType":"Activity","id":"somethingwrong"}}' + ], + 'Invalid multiple statement json' => [ + '[{"actor":{"objectType":"Agent","mbox":"noemail@moodle.org"},"verb":{"id":"InvalidVerb"}' + .',"object":{"objectType":"Activity","id":"somethingwrong"}}]' + ], + ]; + } + + /** + * Testing agent (user) statements. + * + * This function test several scenarios using different combinations + * of statement rejection motives. Some motives produces a full batch + * rejection (exception) and other can leed to indivual rejection on + * each statement. For example,try to post a statement without $USER + * in it produces a full batch rejection, while using an invalid + * verb on one statement just reject that specific statement + * That is the expected behaviour. + * + * @dataProvider statement_provider + * @param bool $multiple if send multiple statements (adds one valid statement) + * @param bool $validactor if the actor used is valid + * @param bool $validverb if the verb used is valid + * @param array $expected expected results + */ + public function test_statements_agent(bool $multiple, bool $validactor, bool $validverb, array $expected) { + global $USER; + + $this->resetAfterTest(); + + $this->setAdminUser(); + + $other = $this->getDataGenerator()->create_user(); + + $info = []; + + // Setup actor. + if ($validactor) { + $info['actor'] = item_agent::create_from_user($USER); + } else { + $info['actor'] = item_agent::create_from_user($other); + } + + // Setup verb. + if (!$validverb) { + $info['verb'] = item_verb::create_from_id('invalid'); + } + + $data = $this->get_valid_statement($info); + + if ($multiple) { + $data = [ + $this->get_valid_statement(), + $data, + ]; + } + + // Perform test. + $this->post_statements_data ('fake_component', $data, $expected); + } + + /** + * Testing group statements. + * + * This function test several scenarios using different combinations + * of statement rejection motives. Some motives produces a full batch + * rejection (exception) and other can leed to indivual rejection on + * each statement. For example,try to post a statement without $USER + * in it produces a full batch rejection, while using an invalid + * verb on one statement just reject that specific statement + * That is the expected behaviour. + * + * @dataProvider statement_provider + * @param bool $multiple if send multiple statements (adds one valid statement) + * @param bool $validactor if the actor used is valid + * @param bool $validverb if the verb used is valid + * @param array $expected expected results + */ + public function test_statements_group(bool $multiple, bool $validactor, bool $validverb, array $expected) { + global $USER, $CFG; + + $this->resetAfterTest(); + + $this->setAdminUser(); + + $other = $this->getDataGenerator()->create_user(); + + $info = []; + + // Enable group mode in the handle. + $CFG->xapitestforcegroupactors = true; + + // Create one course and 1 group. + $course = $this->getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($USER->id, $course->id); + $this->getDataGenerator()->enrol_user($other->id, $course->id); + + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $other->id)); + + if ($validactor) { + // Add $USER into a group to make group valid for processing. + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $USER->id)); + } + $info['actor'] = item_group::create_from_group($group); + + // Setup verb. + if (!$validverb) { + $info['verb'] = item_verb::create_from_id('invalid'); + } + + $data = $this->get_valid_statement($info); + + if ($multiple) { + $data = [ + $this->get_valid_statement(), + $data, + ]; + } + + // Perform test. + $this->post_statements_data ('fake_component', $data, $expected); + } + + /** + * Data provider for the test_components tests. + * + * @return array + */ + public function statement_provider() : array { + return [ + // Single statement with group statements enabled. + 'Single, Valid actor, valid verb' => [ + false, true, true, [true] + ], + 'Single, Invalid actor, valid verb' => [ + false, false, true, [] + ], + 'Single, Valid actor, invalid verb' => [ + false, true, false, [] + ], + 'Single, Inalid actor, invalid verb' => [ + false, false, false, [] + ], + // Multi statement with group statements enabled. + 'Multiple, Valid actor, valid verb' => [ + true, true, true, [true, true] + ], + 'Multiple, Invalid actor, valid verb' => [ + true, false, true, [] + ], + 'Multiple, Valid actor, invalid verb' => [ + true, true, false, [true, false] + ], + 'Multiple, Inalid actor, invalid verb' => [ + true, false, false, [] + ], + ]; + } + + /** + * Test posting group statements to a handler without group actor support. + * + * Try to use group statement in components that not support this feature + * causes a full statements batch rejection. + * + * @dataProvider group_statement_provider + * @param bool $usegroup1 if the 1st statement must be groupal + * @param bool $usegroup2 if the 2nd statement must be groupal + * @param array $expected expected results + */ + public function test_group_disabled(bool $usegroup1, bool $usegroup2, array $expected) { + global $USER; + + $this->resetAfterTest(); + + $this->setAdminUser(); + + // Create one course and 1 group. + $course = $this->getDataGenerator()->create_course(); + $this->getDataGenerator()->enrol_user($USER->id, $course->id); + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $USER->id)); + + $info = ['actor' => item_group::create_from_group($group)]; + + $groupstatement = $this->get_valid_statement($info); + $agentstatement = $this->get_valid_statement(); + + $data = []; + $data[] = ($usegroup1) ? $groupstatement : $agentstatement; + $data[] = ($usegroup2) ? $groupstatement : $agentstatement; + + // Perform test. + $this->post_statements_data ('fake_component', $data, $expected); + } + + /** + * Data provider for the test_components tests. + * + * @return array + */ + public function group_statement_provider() : array { + return [ + // Single statement with group statements enabled. + 'Group statement + group statement without group support' => [ + true, true, [] + ], + 'Group statement + agent statement without group support' => [ + true, false, [] + ], + 'Agent statement + group statement without group support' => [ + true, false, [] + ], + 'Agent statement + agent statement without group support' => [ + false, false, [true, true] + ], + ]; + } + + /** + * Test posting a statements batch not accepted by handler. + * + * If all statements from a batch are rejectes by the plugin the full + * batch is considered rejected and an exception is returned. + */ + public function test_full_batch_rejected() { + $this->resetAfterTest(); + + $this->setAdminUser(); + + $info = ['verb' => item_verb::create_from_id('invalid')]; + + $statement = $this->get_valid_statement($info); + + $data = [$statement, $statement]; + + // Perform test. + $this->post_statements_data ('fake_component', $data, []); + } +} diff --git a/lib/xapi/tests/fixtures/handler.php b/lib/xapi/tests/fixtures/handler.php new file mode 100644 index 00000000000..89cf21ddf05 --- /dev/null +++ b/lib/xapi/tests/fixtures/handler.php @@ -0,0 +1,115 @@ +. + +/** + * The core_xapi test class for xAPI statements. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace fake_component\xapi; + +use core_xapi\local\statement; +use core_xapi\handler as handler_base; +use core_xapi\event\xapi_test_statement_post; +use context_system; +use core\event\base; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class xapi_handler testing dummie class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + */ +class handler extends handler_base { + + /** + * Convert statements to event. + * + * Convert a statement object into a Moodle xAPI Event. If a statement is accepted + * by validate_statement the component must provide a event to handle that statement, + * otherwise the statement will be rejected. + * + * @param statement $statement the statement object + * @return \core\event\base|null a Moodle event to trigger + */ + public function statement_to_event(statement $statement): ?base { + global $USER; + + // Validate verb. + $validvalues = [ + 'cook', + 'http://adlnet.gov/expapi/verbs/answered' + ]; + $verbid = $statement->get_verb_id(); + if (!in_array($verbid, $validvalues)) { + return null; + } + // Validate object. + $validvalues = [ + 'paella', + 'http://adlnet.gov/expapi/activities/example' + ]; + $activityid = $statement->get_activity_id(); + if (!in_array($activityid, $validvalues)) { + return null; + } + + if ($this->supports_group_actors()) { + $users = $statement->get_all_users(); + // In most cases we can use $USER->id as the event userid but because + // this is just a test class it checks first for $USER and, if not + // present just pick the first one. + $user = $users[$USER->id] ?? array_shift($users); + } else { + $user = $statement->get_user(); + } + + // Convert into a Moodle event. + $minstatement = $statement->minify(); + $params = array( + 'other' => $minstatement, + 'context' => context_system::instance(), + 'userid' => $user->id, + ); + return xapi_test_statement_post::create($params); + } + + /** + * Return true if group actor is enabled. + * + * NOTE: the use of a global is only for testing. We need to change + * the behaviour from the PHPUnitTest to test all possible scenarios. + * + * Note: this method must be overridden by the plugins which want to + * use groups in statements. + * + * @return bool + */ + public function supports_group_actors(): bool { + global $CFG; + if (isset($CFG->xapitestforcegroupactors)) { + return $CFG->xapitestforcegroupactors; + } + return parent::supports_group_actors(); + } +} diff --git a/lib/xapi/tests/fixtures/xapi_test_statement_post.php b/lib/xapi/tests/fixtures/xapi_test_statement_post.php new file mode 100644 index 00000000000..c9ae334e9a8 --- /dev/null +++ b/lib/xapi/tests/fixtures/xapi_test_statement_post.php @@ -0,0 +1,102 @@ +. + +/** + * Mock events for xAPI testing. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\event; + +use context_system; +use core_xapi\local\statement; + +defined('MOODLE_INTERNAL') || die(); + +/** + * xAPI statement webservice testing event. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class xapi_test_statement_post extends \core\event\base { + + /** + * Initialise the event data. + */ + protected function init() { + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return "xAPI test statement"; + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "User '$this->userid' send a statement to component '$this->component'"; + } + + /** + * Compare if a given statement is similar to the one on the record. + * + * The information stored in the logstore is not exactly a xAPI standard. + * Similar checks for actor, verb, object (+ definition) and result for now. + * + * @param statement $statement An xAPI compatible statement. + * @return bool True if the $statement represents this event. + */ + public function compare_statement(statement $statement): bool { + // Check minified version. + $calculatedfields = ['actor', 'id', 'timestamp', 'stored', 'version']; + foreach ($calculatedfields as $field) { + if (isset($this->data['other'][$field])) { + return false; + } + } + // Check verb structure. + $data = $statement->get_verb()->get_data(); + if ($this->data['other']['verb']['id'] != $data->id) { + return false; + } + // Check user. + $users = $statement->get_all_users(); + if (empty($users) || !isset($users[$this->data['userid']])) { + return false; + } + // Check object. + $data = $statement->get_object()->get_data(); + if ($this->data['other']['object']['id'] != $data->id) { + return false; + } + return true; + } +} diff --git a/lib/xapi/tests/handler_test.php b/lib/xapi/tests/handler_test.php new file mode 100644 index 00000000000..b8b2dfcae55 --- /dev/null +++ b/lib/xapi/tests/handler_test.php @@ -0,0 +1,129 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi; + +use core_xapi\xapi_exception; +use core_xapi\local\statement; +use core_xapi\local\statement\item_agent; +use core_xapi\local\statement\item_verb; +use core_xapi\local\statement\item_activity; +use advanced_testcase; +use core\event\base; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing xAPI statement handler base methods. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class handler_testcase extends advanced_testcase { + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setupBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot.'/lib/xapi/tests/helper.php'); + } + + /** + * Test handler creation. + */ + public function test_handler_create() { + // Get an existent handler. + $handler = handler::create('fake_component'); + $this->assertEquals(get_class($handler), 'fake_component\\xapi\\handler'); + + // Get a non existent handler. + $this->expectException(xapi_exception::class); + $value = handler::create('potato_omelette'); + } + + /** + * Test support group. + */ + public function test_support_group_actor() { + global $CFG; + // Get an existent handler. + $this->resetAfterTest(); + $handler = handler::create('fake_component'); + $this->assertEquals(get_class($handler), 'fake_component\\xapi\\handler'); + $CFG->xapitestforcegroupactors = false; + $this->assertEquals(false, $handler->supports_group_actors()); + } + + /** + * Test for process_statements method. + */ + public function test_process_statements() { + + $this->resetAfterTest(); + $this->preventResetByRollback(); // Logging waits till the transaction gets committed. + + $user = $this->getDataGenerator()->create_user(); + + $testhelper = new test_helper(); + $testhelper->init_log(); + + // Generate a 2 statements array (one accepted one not). + $statements = []; + + $statement = new statement(); + $statement->set_actor(item_agent::create_from_user($user)); + $statement->set_verb(item_verb::create_from_id('cook')); + $statement->set_object(item_activity::create_from_id('paella')); + $statements[] = $statement; + + $statement2 = new statement(); + $statement2->set_actor(item_agent::create_from_user($user)); + $statement2->set_verb(item_verb::create_from_id('invalid')); + $statement2->set_object(item_activity::create_from_id('paella')); + $statements[] = $statement2; + + $handler = handler::create('fake_component'); + $result = $handler->process_statements($statements); + + // Check results. + $this->assertCount(2, $result); + $this->assertEquals(true, $result[0]); + $this->assertEquals(false, $result[1]); + + // Check log entries. + $log = $testhelper->get_last_log_entry(); + $this->assertNotEmpty($log); + + // Validate statement information on log. + $value = $log->get_name(); + $this->assertEquals($value, 'xAPI test statement'); + $value = $log->get_description(); + // Due to logstore limitation, event must use a real component (core_xapi). + $this->assertEquals($value, 'User \''.$user->id.'\' send a statement to component \'core_xapi\''); + $this->assertTrue($log->compare_statement($statement)); + } +} diff --git a/lib/xapi/tests/helper.php b/lib/xapi/tests/helper.php new file mode 100644 index 00000000000..b42874f7423 --- /dev/null +++ b/lib/xapi/tests/helper.php @@ -0,0 +1,79 @@ +. + +/** + * Unit tests helper for xAPI library. + * + * This file contains unit test helpers related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_xapi; + +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/fixtures/handler.php'); +require_once(__DIR__ . '/fixtures/xapi_test_statement_post.php'); + +/** + * Contains helper functions for xAPI PHPUnit Tests. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class test_helper { + + /** @var \core\log\reader contains a valid logstore reader. */ + private $store; + + /** + * Constructor for a xAPI test helper. + * + */ + public function init_log() { + // Enable logs. + set_config('jsonformat', 1, 'logstore_standard'); + set_config('enabled_stores', 'logstore_standard', 'tool_log'); + set_config('buffersize', 0, 'logstore_standard'); + set_config('logguests', 1, 'logstore_standard'); + $manager = get_log_manager(true); + $stores = $manager->get_readers(); + $this->store = $stores['logstore_standard']; + } + + /** + * Return the last log entry from standardlog. + * + * @return \core\event\base|null The last log event or null if none found. + */ + public function get_last_log_entry(): ?\core\event\base { + + $select = "component = :component"; + $params = ['component' => 'core_xapi']; + $records = $this->store->get_events_select($select, $params, 'id DESC', 0, 1); + + if (empty($records)) { + return null; + } + return array_pop($records); + } +} diff --git a/lib/xapi/tests/iri_test.php b/lib/xapi/tests/iri_test.php new file mode 100644 index 00000000000..1b63d20d3aa --- /dev/null +++ b/lib/xapi/tests/iri_test.php @@ -0,0 +1,139 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_xapi; + +use advanced_testcase; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing xAPI iri class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class iri_testcase extends advanced_testcase { + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setupBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot.'/lib/xapi/tests/helper.php'); + } + + /** + * Test IRI generation. + * + * @dataProvider iri_samples_provider + * @param string $value Value to generate IRI + * @param string $expected Expected result + * @param string $type = null If some special type is provided + */ + public function test_generate(string $value, string $expected, string $type = null) { + $iri = iri::generate($value, $type); + $this->assertEquals($iri, $expected); + } + + /** + * Test IRI extraction. + * + * @dataProvider iri_samples_provider + * @param string $expected Expected result + * @param string $value Value to generate IRI + * @param string $type = null If some special type is provided + */ + public function test_extract(string $expected, string $value, string $type = null) { + $extract = iri::extract($value, $type); + $this->assertEquals($extract, $expected); + } + + /** + * Data provider for the test_generate and test_extract tests. + * + * @return array + */ + public function iri_samples_provider() : array { + global $CFG; + + return [ + 'Fake IRI without type' => [ + 'paella', + "{$CFG->wwwroot}/xapi/element/paella", + null, + ], + 'Real IRI without type' => [ + 'http://adlnet.gov/expapi/activities/example', + 'http://adlnet.gov/expapi/activities/example', + null, + ], + 'Fake IRI with type' => [ + 'paella', + "{$CFG->wwwroot}/xapi/dish/paella", + 'dish', + ], + 'Real IRI with type' => [ + 'http://adlnet.gov/expapi/activities/example', + 'http://adlnet.gov/expapi/activities/example', + 'dish', + ], + ]; + } + + /** + * Test IRI generation. + * + * @dataProvider iri_check_provider + * @param string $value Value to generate IRI + * @param bool $expected Expected result + */ + public function test_check(string $value, bool $expected) { + $check = iri::check($value); + $this->assertEquals($check, $expected); + } + + /** + * Data provider for the test_check. + * + * @return array + */ + public function iri_check_provider() : array { + return [ + 'Real IRI http' => [ + 'http://adlnet.gov/expapi/activities/example', + true, + ], + 'Real IRI https' => [ + 'https://adlnet.gov/expapi/activities/example', + true, + ], + 'Invalid IRI' => [ + 'paella', + false, + ], + ]; + } +} diff --git a/lib/xapi/tests/local/statement/item_activity_test.php b/lib/xapi/tests/local/statement/item_activity_test.php new file mode 100644 index 00000000000..d8acdeca8c2 --- /dev/null +++ b/lib/xapi/tests/local/statement/item_activity_test.php @@ -0,0 +1,193 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use advanced_testcase; +use core_xapi\xapi_exception; +use core_xapi\iri; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement activity class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_activity_testcase extends advanced_testcase { + + /** + * Test item creation. + */ + public function test_creation(): void { + + // Activity without definition. + $data = (object) [ + 'objectType' => 'Activity', + 'id' => iri::generate('paella', 'activity'), + ]; + $item = item_activity::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertEquals($item->get_id(), 'paella'); + $this->assertNull($item->get_definition()); + + // Add optional objectType. + $data->objectType = 'Activity'; + $item = item_activity::create_from_data($data); + $this->assertEquals(json_encode($item), json_encode($data)); + + // Add definition. + $data->definition = (object) [ + 'interactionType' => 'choice', + ]; + $item = item_activity::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertNotNull($item->get_definition()); + } + + /** + * Test item creation from string. + * + * @dataProvider create_from_id_provider + * @param string $id Object string ID (IRI or not) + * @param bool $usedefinition if a valir definition must be attached or not + */ + public function test_create_from_id(string $id, bool $usedefinition): void { + + $definition = null; + if ($usedefinition) { + $data = (object) [ + 'type' => iri::generate('example', 'id'), + 'interactionType' => 'choice' + ]; + $definition = item_definition::create_from_data($data); + } + + $item = item_activity::create_from_id($id, $definition); + + $this->assertEquals($id, $item->get_id()); + $itemdefinition = $item->get_definition(); + if ($usedefinition) { + $this->assertEquals('choice', $itemdefinition->get_interactiontype()); + } else { + $this->assertNull($itemdefinition); + } + + // Check generated data. + $data = $item->get_data(); + $this->assertEquals('Activity', $data->objectType); + $this->assertEquals(iri::generate($id, 'activity'), $data->id); + if ($usedefinition) { + $this->assertEquals('choice', $data->definition->interactionType); + } + } + + /** + * Data provider for the test_create_from_id tests. + * + * @return array + */ + public function create_from_id_provider() : array { + return [ + 'Fake IRI with no definition' => [ + 'paella', false, + ], + 'Fake IRI with definition' => [ + 'paella', true, + ], + 'Real IRI with no definition' => [ + 'http://adlnet.gov/expapi/activities/example', false, + ], + 'Real IRI with definition' => [ + 'http://adlnet.gov/expapi/activities/example', true, + ], + ]; + } + + /** + * Test for invalid structures. + * + * @dataProvider invalid_data_provider + * @param string $type objectType attribute + * @param string $id activity ID + */ + public function test_invalid_data(string $type, string $id): void { + + $data = (object) [ + 'objectType' => $type, + ]; + if (!empty($id)) { + $data->id = $id; + } + + $this->expectException(xapi_exception::class); + $item = item_activity::create_from_data($data); + } + + /** + * Data provider for the test_invalid_data tests. + * + * @return array + */ + public function invalid_data_provider() : array { + return [ + 'Invalid Avtivity objectType' => [ + 'Invalid Type!', iri::generate('paella', 'activity'), + ], + 'Invalid id value' => [ + 'Activity', 'Invalid_iri_value', + ], + 'Non-existent id value' => [ + 'Activity', '', + ], + ]; + } + + /** + * Test for missing object type. + */ + public function test_missing_object_type(): void { + $data = (object) ['id' => 42]; + $this->expectException(xapi_exception::class); + $item = item_activity::create_from_data($data); + } + + /** + * Test for invalid activity objectType. + */ + public function test_inexistent_agent(): void { + global $CFG; + $data = (object) [ + 'objectType' => 'Invalid', + 'id' => -1, + ]; + $this->expectException(xapi_exception::class); + $item = item_activity::create_from_data($data); + } +} diff --git a/lib/xapi/tests/local/statement/item_actor_test.php b/lib/xapi/tests/local/statement/item_actor_test.php new file mode 100644 index 00000000000..d4411df5993 --- /dev/null +++ b/lib/xapi/tests/local/statement/item_actor_test.php @@ -0,0 +1,115 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use advanced_testcase; +use core_xapi\xapi_exception; +use core_xapi\iri; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement actor class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_actor_testcase extends advanced_testcase { + + /** + * Test item creation with agent. + */ + public function test_creation_agent(): void { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + $item = item_agent::create_from_user($user); + $data = $item->get_data(); + + $item = item_actor::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertEquals('core_xapi\local\statement\item_agent', get_class($item)); + + // Create without specify type. + unset($data->objectType); + + $item = item_actor::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertEquals('core_xapi\local\statement\item_agent', get_class($item)); + + // Check user. + $itemuser = $item->get_user(); + $this->assertEquals($itemuser->id, $user->id); + $itemusers = $item->get_all_users(); + $this->assertCount(1, $itemusers); + } + + /** + * Test item creation with group. + */ + public function test_creation_group(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user->id)); + + $item = item_group::create_from_group($group); + $data = $item->get_data(); + + $item = item_actor::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertEquals('core_xapi\local\statement\item_group', get_class($item)); + + // Check group. + $itemgroup = $item->get_group(); + $this->assertEquals($itemgroup->id, $group->id); + $itemusers = $item->get_all_users(); + $this->assertCount(1, $itemusers); + + // Code must prevent from using group as a single user. + $this->expectException(xapi_exception::class); + $itemusers = $item->get_user(); + } + + /** + * Test for invalid structures. + */ + public function test_invalid_data(): void { + $this->expectException(xapi_exception::class); + $data = (object) [ + 'objectType' => 'Fake', + ]; + $item = item_actor::create_from_data($data); + } +} diff --git a/lib/xapi/tests/local/statement/item_agent_test.php b/lib/xapi/tests/local/statement/item_agent_test.php new file mode 100644 index 00000000000..ce233b540b6 --- /dev/null +++ b/lib/xapi/tests/local/statement/item_agent_test.php @@ -0,0 +1,253 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use advanced_testcase; +use core_xapi\xapi_exception; +use core_xapi\iri; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement agent class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_agent_testcase extends advanced_testcase { + + /** + * Test item creation. + */ + public function test_create(): void { + global $CFG; + + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + + // Ceate using account. + $data = (object) [ + 'objectType' => 'Agent', + 'account' => (object) [ + 'homePage' => $CFG->wwwroot, + 'name' => $user->id, + ], + ]; + $item = item_agent::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $itemuser = $item->get_user(); + $this->assertEquals($itemuser->id, $user->id); + $itemusers = $item->get_all_users(); + $this->assertCount(1, $itemusers); + + // Ceate using mbox. + $data = (object) [ + 'objectType' => 'Agent', + 'mbox' => $user->email, + ]; + $item = item_agent::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $itemuser = $item->get_user(); + $this->assertEquals($itemuser->id, $user->id); + $itemusers = $item->get_all_users(); + $this->assertCount(1, $itemusers); + } + + /** + * Test item creation from Record. + */ + public function test_create_from_user(): void { + global $CFG; + + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + + $item = item_agent::create_from_user($user); + + $itemuser = $item->get_user(); + $this->assertEquals($itemuser->id, $user->id); + $itemusers = $item->get_all_users(); + $this->assertCount(1, $itemusers); + + // Check generated data. + $data = $item->get_data(); + $this->assertEquals('Agent', $data->objectType); + $this->assertEquals($CFG->wwwroot, $data->account->homePage); + $this->assertEquals($user->id, $data->account->name); + } + + /** + * Test for invalid structures. + * + * @dataProvider invalid_data_provider + * @param string $objecttype object type attribute + * @param bool $validhome if valid homepage is user + * @param bool $validid if valid group id is used + */ + public function test_invalid_data(string $objecttype, bool $validhome, bool $validid): void { + global $CFG; + + // Create one course with a group if necessary. + $id = 'Wrong ID'; + if ($validid) { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $id = $user->id; + } + + $homepage = 'Invalid homepage!'; + if ($validhome) { + $homepage = $CFG->wwwroot; + } + + $data = (object) [ + 'objectType' => $objecttype, + 'account' => (object) [ + 'homePage' => $homepage, + 'name' => $id, + ], + ]; + + $this->expectException(xapi_exception::class); + $item = item_agent::create_from_data($data); + } + + /** + * Data provider for the test_invalid_data tests. + * + * @return array + */ + public function invalid_data_provider() : array { + return [ + 'Wrong objecttype' => [ + 'Invalid', true, true + ], + 'Wrong homepage' => [ + 'Agent', false, true + ], + 'Wrong id' => [ + 'Agent', true, false + ], + ]; + } + + /** + * Test non supported account identifier xAPI formats. + * + * @dataProvider unspupported_create_provider + * @param bool $usembox + * @param bool $useaccount + * @param bool $usesha1 + * @param bool $useopenid + */ + public function test_unspupported_create(bool $usembox, bool $useaccount, bool $usesha1, bool $useopenid): void { + global $CFG; + + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + + // Ceate using both account and mbox. + $data = (object) [ + 'objectType' => 'Agent' + ]; + + if ($usembox) { + $data->mbox = $user->email; + } + if ($useaccount) { + $data->account = (object) [ + 'homePage' => $CFG->wwwroot, + 'name' => $user->id, + ]; + } + if ($usesha1) { + $data->mbox_sha1sum = sha1($user->email); + } + if ($useopenid) { + // Note: this is not a real openid, it's just a value to test. + $data->openid = 'https://www.moodle.openid.com/accounts/o8/id'; + } + + $this->expectException(xapi_exception::class); + $item = item_agent::create_from_data($data); + } + + /** + * Data provider for the unsupported identifiers tests. + * + * @return array + */ + public function unspupported_create_provider() : array { + return [ + 'Both mbox and account' => [ + true, true, false, false + ], + 'Email SHA1' => [ + false, false, false, false + ], + 'Open ID' => [ + false, false, false, false + ], + ]; + } + + /** + * Test for missing object type. + */ + public function test_missing_object_type(): void { + $data = (object) ['id' => -1]; + $this->expectException(xapi_exception::class); + $item = item_agent::create_from_data($data); + } + + /** + * Test for invalid user id. + */ + public function test_inexistent_agent(): void { + global $CFG; + $data = (object) [ + 'objectType' => 'Agent', + 'account' => (object) [ + 'homePage' => $CFG->wwwroot, + 'name' => 0, + ], + ]; + $this->expectException(xapi_exception::class); + $item = item_agent::create_from_data($data); + } + + /** + * Test for invalid agent record. + */ + public function test_inexistent_agent_id(): void { + $user = (object) ['name' => 'Me']; + $this->expectException(xapi_exception::class); + $item = item_agent::create_from_user($user); + } +} diff --git a/lib/xapi/tests/local/statement/item_definition_test.php b/lib/xapi/tests/local/statement/item_definition_test.php new file mode 100644 index 00000000000..751de2be853 --- /dev/null +++ b/lib/xapi/tests/local/statement/item_definition_test.php @@ -0,0 +1,104 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use advanced_testcase; +use core_xapi\xapi_exception; +use core_xapi\iri; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement definition class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_definition_testcase extends advanced_testcase { + + /** + * Test item_definition creation. + * + * @dataProvider creation_provider + * @param string $interactiontype + */ + public function test_creation(string $interactiontype): void { + + // Activity without interactionType. + $data = (object) [ + 'type' => iri::generate('example', 'id'), + ]; + + // Add interactionType. + if (!empty($interactiontype)) { + $data->interactionType = $interactiontype; + } + $item = item_definition::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + if (empty($interactiontype)) { + $this->assertNull($item->get_interactiontype()); + } else { + $this->assertEquals($interactiontype, $item->get_interactiontype()); + } + } + + /** + * Data provider for the test_creation tests. + * + * @return array + */ + public function creation_provider() : array { + return [ + 'No interactionType' => [''], + 'Choice' => ['choice'], + 'fill-in' => ['fill-in'], + 'long-fill-in' => ['long-fill-in'], + 'true-false' => ['true-false'], + 'matching' => ['matching'], + 'performance' => ['performance'], + 'sequencing' => ['sequencing'], + 'likert' => ['likert'], + 'numeric' => ['numeric'], + 'other' => ['other'], + 'compound' => ['compound'], + ]; + } + + /** + * Test for invalid structures. + */ + public function test_invalid_data(): void { + // Activity without interactionType. + $data = (object) [ + 'interactionType' => 'Invalid value!', + ]; + + $this->expectException(xapi_exception::class); + $item = item_definition::create_from_data($data); + } +} diff --git a/lib/xapi/tests/local/statement/item_group_test.php b/lib/xapi/tests/local/statement/item_group_test.php new file mode 100644 index 00000000000..bd3b863cb3e --- /dev/null +++ b/lib/xapi/tests/local/statement/item_group_test.php @@ -0,0 +1,204 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use advanced_testcase; +use core_xapi\xapi_exception; +use core_xapi\iri; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement group class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_group_testcase extends advanced_testcase { + + /** + * Test item creation. + */ + public function test_create(): void { + global $CFG; + + $this->resetAfterTest(); + + // Create one course with a group. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + $user2 = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user2->id, $course->id); + + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user->id)); + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user2->id)); + + $data = (object) [ + 'objectType' => 'Group', + 'account' => (object) [ + 'homePage' => $CFG->wwwroot, + 'name' => $group->id, + ], + ]; + $item = item_group::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $itemgroup = $item->get_group(); + $this->assertEquals($itemgroup->id, $group->id); + $itemusers = $item->get_all_users(); + $this->assertCount(2, $itemusers); + + // Get user in group item must throw an exception. + $this->expectException(xapi_exception::class); + $itemusers = $item->get_user(); + } + + /** + * Test item creation from Record. + */ + public function test_create_from_group(): void { + global $CFG; + + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + + $item = item_group::create_from_group($group); + + $itemgroup = $item->get_group(); + $this->assertEquals($itemgroup->id, $group->id); + + // Check generated data. + $data = $item->get_data(); + $this->assertEquals('Group', $data->objectType); + $this->assertEquals($CFG->wwwroot, $data->account->homePage); + $this->assertEquals($group->id, $data->account->name); + } + + /** + * Test for invalid structures. + * + * @dataProvider invalid_data_provider + * @param string $objecttype object type attribute + * @param bool $validhome if valid homepage is user + * @param bool $validid if valid group id is used + */ + public function test_invalid_data(string $objecttype, bool $validhome, bool $validid): void { + global $CFG; + + // Create one course with a group if necessary. + $id = 'Wrong ID'; + if ($validid) { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(); + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $id = $group->id; + } + + $homepage = 'Invalid homepage!'; + if ($validhome) { + $homepage = $CFG->wwwroot; + } + + $data = (object) [ + 'objectType' => $objecttype, + 'account' => (object) [ + 'homePage' => $homepage, + 'name' => $id, + ], + ]; + + $this->expectException(xapi_exception::class); + $item = item_group::create_from_data($data); + } + + /** + * Data provider for the test_invalid_data tests. + * + * @return array + */ + public function invalid_data_provider() : array { + return [ + 'Wrong objecttype' => [ + 'Invalid', true, true + ], + 'Wrong homepage' => [ + 'Group', false, true + ], + 'Wrong id' => [ + 'Group', true, false + ], + ]; + } + + /** + * Test for missing object type. + */ + public function test_missing_object_type(): void { + $data = (object) ['id' => -1]; + $this->expectException(xapi_exception::class); + $item = item_group::create_from_data($data); + } + + /** + * Test for invalid anonymous group. + */ + public function test_invalid_anonymous_group(): void { + $data = (object) [ + 'objectType' => 'Group' + ]; + $this->expectException(xapi_exception::class); + $item = item_group::create_from_data($data); + } + + /** + * Test for invalid anonymous group. + */ + public function test_inexistent_group(): void { + global $CFG; + $data = (object) [ + 'objectType' => 'Group', + 'account' => (object) [ + 'homePage' => $CFG->wwwroot, + 'name' => 0, + ], + ]; + $this->expectException(xapi_exception::class); + $item = item_group::create_from_data($data); + } + + /** + * Test for invalid group record. + */ + public function test_inexistent_group_id(): void { + $group = (object) ['name' => 'My Group']; + $this->expectException(xapi_exception::class); + $item = item_group::create_from_group($group); + } +} diff --git a/lib/xapi/tests/local/statement/item_object_test.php b/lib/xapi/tests/local/statement/item_object_test.php new file mode 100644 index 00000000000..dc5cbae8f23 --- /dev/null +++ b/lib/xapi/tests/local/statement/item_object_test.php @@ -0,0 +1,130 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use advanced_testcase; +use core_xapi\xapi_exception; +use core_xapi\iri; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement object class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_object_testcase extends advanced_testcase { + + /** + * Test item creation with agent. + */ + public function test_creation_agent(): void { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + $item = item_agent::create_from_user($user); + $data = $item->get_data(); + + $item = item_object::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertEquals('core_xapi\local\statement\item_agent', get_class($item)); + } + + /** + * Test item creation with group. + */ + public function test_creation_group(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $item = item_group::create_from_group($group); + $data = $item->get_data(); + + $item = item_object::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertEquals('core_xapi\local\statement\item_group', get_class($item)); + } + + /** + * Test item creation with activity. + */ + public function test_creation_activity(): void { + + $item = item_activity::create_from_id('paella'); + $data = $item->get_data(); + $item = item_object::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertEquals('core_xapi\local\statement\item_activity', get_class($item)); + } + + /** + * Test unsupported item creation. + */ + public function test_unsupported_activity(): void { + $this->expectException(xapi_exception::class); + $data = (object) [ + 'objectType' => 'FakeType', + 'id' => -1, + ]; + $item = item_object::create_from_data($data); + } + + /** + * Test for invalid structures. + * + * @dataProvider invalid_data_provider + * @param string $id + */ + public function test_invalid_data(string $id): void { + $this->expectException(xapi_exception::class); + $data = (object) [ + 'id' => $id, + ]; + $item = item_object::create_from_data($data); + } + + /** + * Data provider for the test_invalid_data tests. + * + * @return array + */ + public function invalid_data_provider() : array { + return [ + 'Empty or null id' => [ + '', + ], + 'Invalid IRI value' => [ + 'invalid_iri_value', + ], + ]; + } +} diff --git a/lib/xapi/tests/local/statement/item_test.php b/lib/xapi/tests/local/statement/item_test.php new file mode 100644 index 00000000000..cfc8e50d7f4 --- /dev/null +++ b/lib/xapi/tests/local/statement/item_test.php @@ -0,0 +1,58 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use advanced_testcase; +use core_xapi\xapi_exception; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement base class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_testcase extends advanced_testcase { + + /** + * Test item creation. + */ + public function test_create() { + + // This is a generic item so check that it can create and item and json encode later. + $data = (object) [ + 'this' => 'is', + 'just' => 1, + 'example' => ['of', 'structure'], + ]; + $item = item::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + + } +} diff --git a/lib/xapi/tests/local/statement/item_verb_test.php b/lib/xapi/tests/local/statement/item_verb_test.php new file mode 100644 index 00000000000..3c714403919 --- /dev/null +++ b/lib/xapi/tests/local/statement/item_verb_test.php @@ -0,0 +1,118 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local\statement; + +use advanced_testcase; +use core_xapi\xapi_exception; +use core_xapi\iri; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement verb class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class item_verb_testcase extends advanced_testcase { + + /** + * Test item creation. + */ + public function test_creation(): void { + + $data = (object) [ + 'id' => iri::generate('cook', 'verb'), + ]; + $item = item_verb::create_from_data($data); + + $this->assertEquals(json_encode($item), json_encode($data)); + $this->assertEquals($item->get_id(), 'cook'); + } + + /** + * Test item creation from string. + * + * @dataProvider create_from_id_provider + * @param string $id Object string ID (IRI or not) + */ + public function test_create_from_id(string $id): void { + $item = item_verb::create_from_id($id); + + $this->assertEquals($id, $item->get_id()); + + // Check generated data. + $data = $item->get_data(); + $this->assertEquals(iri::generate($id, 'verb'), $data->id); + } + + /** + * Data provider for the test_create_from_id tests. + * + * @return array + */ + public function create_from_id_provider() : array { + return [ + 'Fake IRI' => [ + 'cook', + ], + 'Real IRI' => [ + 'http://adlnet.gov/expapi/verb/example', + ], + ]; + } + + /** + * Test for invalid structures. + * + * @dataProvider invalid_data_provider + * @param string $id + */ + public function test_invalid_data(string $id): void { + $this->expectException(xapi_exception::class); + $data = (object) [ + 'id' => $id, + ]; + $item = item_verb::create_from_data($data); + } + + /** + * Data provider for the test_invalid_data tests. + * + * @return array + */ + public function invalid_data_provider() : array { + return [ + 'Empty or null id' => [ + '', + ], + 'Invalid IRI value' => [ + 'invalid_iri_value', + ], + ]; + } +} diff --git a/lib/xapi/tests/local/statement_test.php b/lib/xapi/tests/local/statement_test.php new file mode 100644 index 00000000000..04317b79ce4 --- /dev/null +++ b/lib/xapi/tests/local/statement_test.php @@ -0,0 +1,451 @@ +. + +/** + * This file contains unit test related to xAPI library. + * + * @package core_xapi + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_xapi\local; + +use core_xapi\local\statement\item; +use core_xapi\local\statement\item_actor; +use core_xapi\local\statement\item_object; +use core_xapi\local\statement\item_activity; +use core_xapi\local\statement\item_verb; +use core_xapi\local\statement\item_agent; +use core_xapi\local\statement\item_group; +use core_xapi\xapi_exception; +use advanced_testcase; +use stdClass; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Contains test cases for testing statement class. + * + * @package core_xapi + * @since Moodle 3.9 + * @copyright 2020 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class statement_testcase extends advanced_testcase { + + /** + * Test statement creation. + * + * @dataProvider create_provider + * @param bool $useagent if use agent as actor (or group if false) + * @param array $extras + */ + public function test_create(bool $useagent, array $extras) { + + $this->resetAfterTest(); + + // Create one course with a group. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user->id)); + + // Our statement. + $statement = new statement(); + + // Populate statement. + if ($useagent) { + $statement->set_actor(item_agent::create_from_user($user)); + } else { + $statement->set_actor(item_group::create_from_group($group)); + } + $statement->set_verb(item_verb::create_from_id('cook')); + $statement->set_object(item_activity::create_from_id('paella')); + // For now, the rest of the optional properties have no validation + // so we create a standard stdClass for all of them. + $data = (object)[ + 'some' => 'data', + ]; + foreach ($extras as $extra) { + $method = 'set_'.$extra; + $statement->$method(item::create_from_data($data)); + } + + // Check resulting statement. + if ($useagent) { + $stuser = $statement->get_user(); + $this->assertEquals($user->id, $stuser->id); + $stusers = $statement->get_all_users(); + $this->assertCount(1, $stusers); + } else { + $stgroup = $statement->get_group(); + $this->assertEquals($group->id, $stgroup->id); + $stusers = $statement->get_all_users(); + $this->assertCount(1, $stusers); + $stuser = array_shift($stusers); + $this->assertEquals($user->id, $stuser->id); + } + $this->assertEquals('cook', $statement->get_verb_id()); + $this->assertEquals('paella', $statement->get_activity_id()); + + // Check resulting json (only first node structure, internal structure + // depends on every item json_encode test). + $data = json_decode(json_encode($statement)); + $this->assertNotEmpty($data->actor); + $this->assertNotEmpty($data->verb); + $this->assertNotEmpty($data->object); + $allextras = ['context', 'result', 'timestamp', 'stored', 'authority', 'version', 'attachments']; + foreach ($allextras as $extra) { + if (in_array($extra, $extras)) { + $this->assertObjectHasAttribute($extra, $data); + $this->assertNotEmpty($data->object); + } else { + $this->assertObjectNotHasAttribute($extra, $data); + } + } + } + + /** + * Data provider for the test_create and test_create_from_data tests. + * + * @return array + */ + public function create_provider() : array { + return [ + 'Agent statement with no extras' => [ + true, [] + ], + 'Agent statement with context' => [ + true, ['context'] + ], + 'Agent statement with result' => [ + true, ['result'] + ], + 'Agent statement with timestamp' => [ + true, ['timestamp'] + ], + 'Agent statement with stored' => [ + true, ['stored'] + ], + 'Agent statement with authority' => [ + true, ['authority'] + ], + 'Agent statement with version' => [ + true, ['version'] + ], + 'Agent statement with attachments' => [ + true, ['attachments'] + ], + 'Group statement with no extras' => [ + false, [] + ], + 'Group statement with context' => [ + false, ['context'] + ], + 'Group statement with result' => [ + false, ['result'] + ], + 'Group statement with timestamp' => [ + false, ['timestamp'] + ], + 'Group statement with stored' => [ + false, ['stored'] + ], + 'Group statement with authority' => [ + false, ['authority'] + ], + 'Group statement with version' => [ + false, ['version'] + ], + 'Group statement with attachments' => [ + false, ['attachments'] + ], + ]; + } + + /** + * Test statement creation from xAPI statement data. + * + * @dataProvider create_provider + * @param bool $useagent if use agent as actor (or group if false) + * @param array $extras + */ + public function test_create_from_data(bool $useagent, array $extras) { + $this->resetAfterTest(); + + // Create one course with a group. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user->id)); + + // Populate data. + if ($useagent) { + $actor = item_agent::create_from_user($user); + } else { + $actor = item_group::create_from_group($group); + } + $verb = item_verb::create_from_id('cook'); + $object = item_activity::create_from_id('paella'); + + $data = (object) [ + 'actor' => $actor->get_data(), + 'verb' => $verb->get_data(), + 'object' => $object->get_data(), + ]; + // For now, the rest of the optional properties have no validation + // so we create a standard stdClass for all of them. + $info = (object)[ + 'some' => 'data', + ]; + foreach ($extras as $extra) { + $data->$extra = $info; + } + + $statement = statement::create_from_data($data); + + // Check resulting statement. + if ($useagent) { + $stuser = $statement->get_user(); + $this->assertEquals($user->id, $stuser->id); + $stusers = $statement->get_all_users(); + $this->assertCount(1, $stusers); + } else { + $stgroup = $statement->get_group(); + $this->assertEquals($group->id, $stgroup->id); + $stusers = $statement->get_all_users(); + $this->assertCount(1, $stusers); + $stuser = array_shift($stusers); + $this->assertEquals($user->id, $stuser->id); + } + $this->assertEquals('cook', $statement->get_verb_id()); + $this->assertEquals('paella', $statement->get_activity_id()); + + // Check resulting json (only first node structure, internal structure + // depends on every item json_encode test). + $data = json_decode(json_encode($statement)); + $this->assertNotEmpty($data->actor); + $this->assertNotEmpty($data->verb); + $this->assertNotEmpty($data->object); + $allextras = ['context', 'result', 'timestamp', 'stored', 'authority', 'version', 'attachments']; + foreach ($allextras as $extra) { + if (in_array($extra, $extras)) { + $this->assertObjectHasAttribute($extra, $data); + $this->assertNotEmpty($data->object); + } else { + $this->assertObjectNotHasAttribute($extra, $data); + } + } + } + + /** + * Test all getters into a not set statement. + * + * @dataProvider invalid_gets_provider + * @param string $method the method to test + * @param bool $exception if an exception is expected + */ + public function test_invalid_gets(string $method, bool $exception) { + $statement = new statement(); + if ($exception) { + $this->expectException(xapi_exception::class); + } + $result = $statement->$method(); + $this->assertNull($result); + } + + /** + * Data provider for the text_invalid_gets. + * + * @return array + */ + public function invalid_gets_provider() : array { + return [ + 'Method get_user on empty statement' => ['get_user', true], + 'Method get_all_users on empty statement' => ['get_all_users', true], + 'Method get_group on empty statement' => ['get_group', true], + 'Method get_verb_id on empty statement' => ['get_verb_id', true], + 'Method get_activity_id on empty statement' => ['get_activity_id', true], + 'Method get_actor on empty statement' => ['get_actor', false], + 'Method get_verb on empty statement' => ['get_verb', false], + 'Method get_object on empty statement' => ['get_object', false], + 'Method get_context on empty statement' => ['get_context', false], + 'Method get_result on empty statement' => ['get_result', false], + 'Method get_timestamp on empty statement' => ['get_timestamp', false], + 'Method get_stored on empty statement' => ['get_stored', false], + 'Method get_authority on empty statement' => ['get_authority', false], + 'Method get_version on empty statement' => ['get_version', false], + 'Method get_attachments on empty statement' => ['get_attachments', false], + ]; + } + + /** + * Try to get a user from a group statement. + */ + public function test_invalid_get_user() { + + $this->resetAfterTest(); + + // Create one course with a group. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($user->id, $course->id); + $group = $this->getDataGenerator()->create_group(array('courseid' => $course->id)); + $this->getDataGenerator()->create_group_member(array('groupid' => $group->id, 'userid' => $user->id)); + + // Our statement. + $statement = new statement(); + + // Populate statement. + $statement->set_actor(item_group::create_from_group($group)); + $statement->set_verb(item_verb::create_from_id('cook')); + $statement->set_object(item_activity::create_from_id('paella')); + + $this->expectException(xapi_exception::class); + $statement->get_user(); + } + + /** + * Try to get a group from an agent statement. + */ + public function test_invalid_get_group() { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + // Our statement. + $statement = new statement(); + + // Populate statement. + $statement->set_actor(item_agent::create_from_user($user)); + $statement->set_verb(item_verb::create_from_id('cook')); + $statement->set_object(item_activity::create_from_id('paella')); + + $this->expectException(xapi_exception::class); + $statement->get_group(); + } + + /** + * Try to get activity Id from a statement with agent object. + */ + public function test_invalid_get_activity_id() { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + // Our statement. + $statement = new statement(); + + // Populate statement with and agent object. + $statement->set_actor(item_agent::create_from_user($user)); + $statement->set_verb(item_verb::create_from_id('cook')); + $statement->set_object(item_agent::create_from_user($user)); + + $this->expectException(xapi_exception::class); + $statement->get_activity_id(); + } + + /** + * Test for invalid structures. + * + * @dataProvider invalid_data_provider + * @param bool $useuser if use user into statement + * @param bool $userverb if use verb into statement + * @param bool $useobject if use object into statement + */ + public function test_invalid_data(bool $useuser, bool $userverb, bool $useobject): void { + + $data = new stdClass(); + + if ($useuser) { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $data->actor = item_agent::create_from_user($user); + } + if ($userverb) { + $data->verb = item_verb::create_from_id('cook'); + } + if ($useobject) { + $data->object = item_activity::create_from_id('paella'); + } + + $this->expectException(xapi_exception::class); + $statement = statement::create_from_data($data); + } + + /** + * Data provider for the test_invalid_data tests. + * + * @return array + */ + public function invalid_data_provider() : array { + return [ + 'No actor, no verb, no object' => [false, false, false], + 'No actor, verb, no object' => [false, true, false], + 'No actor, no verb, object' => [false, false, true], + 'No actor, verb, object' => [false, true, true], + 'Actor, no verb, no object' => [true, false, false], + 'Actor, verb, no object' => [true, true, false], + 'Actor, no verb, object' => [true, false, true], + ]; + } + + /** + * Test minify statement. + */ + public function test_minify() { + + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + + // Our statement. + $statement = new statement(); + + // Populate statement. + $statement->set_actor(item_agent::create_from_user($user)); + $statement->set_verb(item_verb::create_from_id('cook')); + $statement->set_object(item_activity::create_from_id('paella')); + // For now, the rest of the optional properties have no validation + // so we create a standard stdClass for all of them. + $data = (object)[ + 'some' => 'data', + ]; + $extras = ['context', 'result', 'timestamp', 'stored', 'authority', 'version', 'attachments']; + foreach ($extras as $extra) { + $method = 'set_'.$extra; + $statement->$method(item::create_from_data($data)); + } + + $min = $statement->minify(); + + // Check calculated fields. + $this->assertCount(6, $min); + $this->assertArrayNotHasKey('actor', $min); + $this->assertArrayHasKey('verb', $min); + $this->assertArrayHasKey('object', $min); + $this->assertArrayHasKey('context', $min); + $this->assertArrayHasKey('result', $min); + $this->assertArrayNotHasKey('timestamp', $min); + $this->assertArrayNotHasKey('stored', $min); + $this->assertArrayHasKey('authority', $min); + $this->assertArrayNotHasKey('version', $min); + $this->assertArrayHasKey('attachments', $min); + } +} diff --git a/version.php b/version.php index cbc67e6b940..a62461c998a 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2020032700.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2020032700.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '3.9dev (Build: 20200327)'; // Human-friendly version name