Files
moodle/lib/external/tests/external_api_test.php
T
Jake Dallimore 36ab70c64a MDL-79843 core_external: test covering services for deprecated plugins
This just asserts that web services provided by deprecated plugins can
still be used (similar to how their APIs can still be used during the
deprecation period).
2025-01-28 14:42:20 +08:00

536 lines
23 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_external;
use core\tests\fake_plugins_test_trait;
/**
* Unit tests for core_external\external_api.
*
* @package core_external
* @category test
* @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU Public License
* @covers \core_external\external_api
*/
final class external_api_test extends \advanced_testcase {
use fake_plugins_test_trait;
/**
* Test the validate_parameters method.
*
* @covers \core_external\external_api::validate_parameters
*/
public function test_validate_params(): void {
$params = ['text' => 'aaa', 'someid' => '6'];
$description = new external_function_parameters([
'someid' => new external_value(PARAM_INT, 'Some int value'),
'text' => new external_value(PARAM_ALPHA, 'Some text value'),
]);
$result = external_api::validate_parameters($description, $params);
$this->assertCount(2, $result);
reset($result);
$this->assertSame('someid', key($result));
$this->assertSame(6, $result['someid']);
$this->assertSame('aaa', $result['text']);
$params = [
'someids' => ['1', 2, 'a' => '3'],
'scalar' => 666,
];
$description = new external_function_parameters([
'someids' => new external_multiple_structure(new external_value(PARAM_INT, 'Some ID')),
'scalar' => new external_value(PARAM_ALPHANUM, 'Some text value'),
]);
$result = external_api::validate_parameters($description, $params);
$this->assertCount(2, $result);
reset($result);
$this->assertSame('someids', key($result));
$this->assertEquals([0 => 1, 1 => 2, 2 => 3], $result['someids']);
$this->assertSame('666', $result['scalar']);
$params = ['text' => 'aaa'];
$description = new external_function_parameters([
'someid' => new external_value(PARAM_INT, 'Some int value', VALUE_DEFAULT),
'text' => new external_value(PARAM_ALPHA, 'Some text value'),
]);
$result = external_api::validate_parameters($description, $params);
$this->assertCount(2, $result);
reset($result);
$this->assertSame('someid', key($result));
$this->assertNull($result['someid']);
$this->assertSame('aaa', $result['text']);
$params = ['text' => 'aaa'];
$description = new external_function_parameters([
'someid' => new external_value(PARAM_INT, 'Some int value', VALUE_DEFAULT, 6),
'text' => new external_value(PARAM_ALPHA, 'Some text value'),
]);
$result = external_api::validate_parameters($description, $params);
$this->assertCount(2, $result);
reset($result);
$this->assertSame('someid', key($result));
$this->assertSame(6, $result['someid']);
$this->assertSame('aaa', $result['text']);
// Missing required value (an exception is thrown).
$testdata = [];
try {
external_api::clean_returnvalue($description, $testdata);
$this->fail('Exception expected');
} catch (\moodle_exception $ex) {
$this->assertInstanceOf(\invalid_response_exception::class, $ex);
$this->assertSame('Invalid response value detected (Error in response - '
. 'Missing following required key in a single structure: text)', $ex->getMessage());
}
// Test nullable external_value may optionally return data.
$description = new external_function_parameters([
'value' => new external_value(PARAM_INT, '', VALUE_REQUIRED, null, NULL_ALLOWED)
]);
$testdata = ['value' => null];
$cleanedvalue = external_api::clean_returnvalue($description, $testdata);
$this->assertSame($testdata, $cleanedvalue);
$testdata = ['value' => 1];
$cleanedvalue = external_api::clean_returnvalue($description, $testdata);
$this->assertSame($testdata, $cleanedvalue);
// Test nullable external_single_structure may optionally return data.
$description = new external_function_parameters([
'value' => new external_single_structure(['value2' => new external_value(PARAM_INT)],
'', VALUE_REQUIRED, null, NULL_ALLOWED)
]);
$testdata = ['value' => null];
$cleanedvalue = external_api::clean_returnvalue($description, $testdata);
$this->assertSame($testdata, $cleanedvalue);
$testdata = ['value' => ['value2' => 1]];
$cleanedvalue = external_api::clean_returnvalue($description, $testdata);
$this->assertSame($testdata, $cleanedvalue);
// Test nullable external_multiple_structure may optionally return data.
$description = new external_function_parameters([
'value' => new external_multiple_structure(
new external_value(PARAM_INT), '', VALUE_REQUIRED, null, NULL_ALLOWED)
]);
$testdata = ['value' => null];
$cleanedvalue = external_api::clean_returnvalue($description, $testdata);
$this->assertSame($testdata, $cleanedvalue);
$testdata = ['value' => [1]];
$cleanedvalue = external_api::clean_returnvalue($description, $testdata);
$this->assertSame($testdata, $cleanedvalue);
}
/**
* Test for clean_returnvalue() for testing that returns the PHP type.
*
* @covers \core_external\external_api::clean_returnvalue
*/
public function test_clean_returnvalue_return_php_type(): void {
$returndesc = new external_single_structure([
'value' => new external_value(PARAM_RAW, 'Some text', VALUE_OPTIONAL, null, NULL_NOT_ALLOWED),
]);
// Check return type on exception because the external values does not allow NULL values.
$testdata = ['value' => null];
try {
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
} catch (\moodle_exception $e) {
$this->assertInstanceOf(\invalid_response_exception::class, $e);
$this->assertStringContainsString('of PHP type "NULL"', $e->debuginfo);
}
}
/**
* Test for clean_returnvalue().
*
* @covers \core_external\external_api::clean_returnvalue
*/
public function test_clean_returnvalue(): void {
// Build some return value decription.
$returndesc = new external_multiple_structure(
new external_single_structure(
[
'object' => new external_single_structure(
['value1' => new external_value(PARAM_INT, 'this is a int')]),
'value2' => new external_value(PARAM_TEXT, 'some text', VALUE_OPTIONAL),
]
));
// Clean an object (it should be cast into an array).
$object = new \stdClass();
$object->value1 = 1;
$singlestructure['object'] = $object;
$singlestructure['value2'] = 'Some text';
$testdata = [$singlestructure];
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$cleanedsinglestructure = array_pop($cleanedvalue);
$this->assertSame($object->value1, $cleanedsinglestructure['object']['value1']);
$this->assertSame($singlestructure['value2'], $cleanedsinglestructure['value2']);
// Missing VALUE_OPTIONAL.
$object = new \stdClass();
$object->value1 = 1;
$singlestructure = new \stdClass();
$singlestructure->object = $object;
$testdata = [$singlestructure];
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$cleanedsinglestructure = array_pop($cleanedvalue);
$this->assertSame($object->value1, $cleanedsinglestructure['object']['value1']);
$this->assertArrayNotHasKey('value2', $cleanedsinglestructure);
// Unknown attribute (the value should be ignored).
$object = [];
$object['value1'] = 1;
$singlestructure = [];
$singlestructure['object'] = $object;
$singlestructure['value2'] = 'Some text';
$singlestructure['unknownvalue'] = 'Some text to ignore';
$testdata = [$singlestructure];
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$cleanedsinglestructure = array_pop($cleanedvalue);
$this->assertSame($object['value1'], $cleanedsinglestructure['object']['value1']);
$this->assertSame($singlestructure['value2'], $cleanedsinglestructure['value2']);
$this->assertArrayNotHasKey('unknownvalue', $cleanedsinglestructure);
// Missing required value (an exception is thrown).
$object = [];
$singlestructure = [];
$singlestructure['object'] = $object;
$singlestructure['value2'] = 'Some text';
$testdata = [$singlestructure];
try {
external_api::clean_returnvalue($returndesc, $testdata);
$this->fail('Exception expected');
} catch (\moodle_exception $ex) {
$this->assertInstanceOf(\invalid_response_exception::class, $ex);
$this->assertSame('Invalid response value detected (object => Invalid response value detected '
. '(Error in response - Missing following required key in a single structure: value1): Error in response - '
. 'Missing following required key in a single structure: value1)', $ex->getMessage());
}
// Fail if no data provided when value required.
$testdata = null;
try {
external_api::clean_returnvalue($returndesc, $testdata);
$this->fail('Exception expected');
} catch (\moodle_exception $ex) {
$this->assertInstanceOf(\invalid_response_exception::class, $ex);
$this->assertSame('Invalid response value detected (Only arrays accepted. The bad value is: \'\')',
$ex->getMessage());
}
// Test nullable external_multiple_structure may optionally return data.
$returndesc = new external_multiple_structure(
new external_value(PARAM_INT),
'', VALUE_REQUIRED, null, NULL_ALLOWED);
$testdata = null;
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$this->assertSame($testdata, $cleanedvalue);
$testdata = [1];
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$this->assertSame($testdata, $cleanedvalue);
// Test nullable external_single_structure may optionally return data.
$returndesc = new external_single_structure(['value' => new external_value(PARAM_INT)],
'', VALUE_REQUIRED, null, NULL_ALLOWED);
$testdata = null;
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$this->assertSame($testdata, $cleanedvalue);
$testdata = ['value' => 1];
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$this->assertSame($testdata, $cleanedvalue);
// Test nullable external_value may optionally return data.
$returndesc = new external_value(PARAM_INT, '', VALUE_REQUIRED, null, NULL_ALLOWED);
$testdata = null;
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$this->assertSame($testdata, $cleanedvalue);
$testdata = 1;
$cleanedvalue = external_api::clean_returnvalue($returndesc, $testdata);
$this->assertSame($testdata, $cleanedvalue);
}
/**
* Test \core_external\external_api::get_context_from_params().
*
* @covers \core_external\external_api::get_context_from_params
*/
public function test_get_context_from_params(): void {
$this->resetAfterTest(true);
$course = $this->getDataGenerator()->create_course();
$realcontext = \context_course::instance($course->id);
// Use context id.
$fetchedcontext = $this->get_context_from_params(["contextid" => $realcontext->id]);
$this->assertEquals($realcontext, $fetchedcontext);
// Use context level and instance id.
$fetchedcontext = $this->get_context_from_params(["contextlevel" => "course", "instanceid" => $course->id]);
$this->assertEquals($realcontext, $fetchedcontext);
// Use context level numbers instead of legacy short level names.
$fetchedcontext = $this->get_context_from_params(
["contextlevel" => \core\context\course::LEVEL, "instanceid" => $course->id]);
$this->assertEquals($realcontext, $fetchedcontext);
// Passing empty values.
try {
$fetchedcontext = $this->get_context_from_params(["contextid" => 0]);
$this->fail('Exception expected from get_context_wrapper()');
} catch (\moodle_exception $e) {
$this->assertInstanceOf(\invalid_parameter_exception::class, $e);
}
try {
$fetchedcontext = $this->get_context_from_params(["instanceid" => 0]);
$this->fail('Exception expected from get_context_wrapper()');
} catch (\moodle_exception $e) {
$this->assertInstanceOf(\invalid_parameter_exception::class, $e);
}
try {
$fetchedcontext = $this->get_context_from_params(["contextid" => null]);
$this->fail('Exception expected from get_context_wrapper()');
} catch (\moodle_exception $e) {
$this->assertInstanceOf(\invalid_parameter_exception::class, $e);
}
// Tests for context with instanceid equal to 0 (System context).
$realcontext = \context_system::instance();
$fetchedcontext = $this->get_context_from_params(["contextlevel" => "system", "instanceid" => 0]);
$this->assertEquals($realcontext, $fetchedcontext);
// Passing wrong level name.
try {
$fetchedcontext = $this->get_context_from_params(["contextlevel" => "random", "instanceid" => $course->id]);
$this->fail('exception expected when level name is invalid');
} catch (\moodle_exception $e) {
$this->assertInstanceOf('invalid_parameter_exception', $e);
$this->assertSame('Invalid parameter value detected (Invalid context level = random)', $e->getMessage());
}
// Passing wrong level number.
try {
$fetchedcontext = $this->get_context_from_params(["contextlevel" => -10, "instanceid" => $course->id]);
$this->fail('exception expected when level name is invalid');
} catch (\moodle_exception $e) {
$this->assertInstanceOf('invalid_parameter_exception', $e);
$this->assertSame('Invalid parameter value detected (Invalid context level = -10)', $e->getMessage());
}
}
/**
* Test \core_external\external_api::get_context()_from_params parameter validation.
*
* @covers \core_external\external_api::get_context_from_params
*/
public function test_get_context_params(): void {
global $USER;
// Call without correct context details.
$this->expectException('invalid_parameter_exception');
$this->get_context_from_params(['roleid' => 3, 'userid' => $USER->id]);
}
/**
* Test \core_external\external_api::get_context()_from_params parameter validation.
*
* @covers \core_external\external_api::get_context_from_params
*/
public function test_get_context_params2(): void {
global $USER;
// Call without correct context details.
$this->expectException('invalid_parameter_exception');
$this->get_context_from_params(['roleid' => 3, 'userid' => $USER->id, 'contextlevel' => "course"]);
}
/**
* Test \core_external\external_api::get_context()_from_params parameter validation.
* @covers \core_external\external_api::get_context_from_params
*/
public function test_get_context_params3(): void {
global $USER;
// Call without correct context details.
$this->resetAfterTest(true);
$course = self::getDataGenerator()->create_course();
$this->expectException('invalid_parameter_exception');
$this->get_context_from_params(['roleid' => 3, 'userid' => $USER->id, 'instanceid' => $course->id]);
}
/**
* Data provider for the test_all_external_info test.
*
* @return array
*/
public static function all_external_info_provider(): array {
global $DB;
// We are testing here that all the external function descriptions can be generated without
// producing warnings. E.g. misusing optional params will generate a debugging message which
// will fail this test.
$functions = $DB->get_records('external_functions', [], 'name');
$return = [];
foreach ($functions as $f) {
$return[$f->name] = [$f];
}
return $return;
}
/**
* Test \core_external\external_api::external_function_info.
*
* @runInSeparateProcess
* @dataProvider all_external_info_provider
* @covers \core_external\external_api::external_function_info
* @param \stdClass $definition
*/
public function test_all_external_info(\stdClass $definition): void {
$desc = external_api::external_function_info($definition);
$this->assertNotEmpty($desc->name);
$this->assertNotEmpty($desc->classname);
$this->assertNotEmpty($desc->methodname);
$this->assertEquals($desc->component, clean_param($desc->component, PARAM_COMPONENT));
$this->assertInstanceOf(external_function_parameters::class, $desc->parameters_desc);
if ($desc->returns_desc != null) {
$this->assertInstanceOf(external_description::class, $desc->returns_desc);
}
}
/**
* Test the \core_external\external_api::call_external_function() function.
*
* @covers \core_external\external_api::call_external_function
*/
public function test_call_external_function(): void {
global $PAGE, $COURSE, $CFG;
$this->resetAfterTest(true);
// Call some webservice functions and verify they are correctly handling $PAGE and $COURSE.
// First test a function that calls validate_context outside a course.
$this->setAdminUser();
$category = $this->getDataGenerator()->create_category();
$params = [
'contextid' => \context_coursecat::instance($category->id)->id,
'name' => 'aaagrrryyy',
'idnumber' => '',
'description' => '',
];
$cohort1 = $this->getDataGenerator()->create_cohort($params);
$cohort2 = $this->getDataGenerator()->create_cohort();
$beforepage = $PAGE;
$beforecourse = $COURSE;
$params = ['cohortids' => [$cohort1->id, $cohort2->id]];
$result = external_api::call_external_function('core_cohort_get_cohorts', $params);
$this->assertSame($beforepage, $PAGE);
$this->assertSame($beforecourse, $COURSE);
// Now test a function that calls validate_context inside a course.
$course = $this->getDataGenerator()->create_course();
$beforepage = $PAGE;
$beforecourse = $COURSE;
$params = ['courseid' => $course->id, 'options' => []];
$result = external_api::call_external_function('core_enrol_get_enrolled_users', $params);
$this->assertSame($beforepage, $PAGE);
$this->assertSame($beforecourse, $COURSE);
// Test a function that triggers a PHP exception.
require_once($CFG->dirroot . '/lib/tests/fixtures/test_external_function_throwable.php');
// Call our test function.
$result = \test_external_function_throwable::call_external_function('core_throw_exception', [], false);
$this->assertTrue($result['error']);
$this->assertArrayHasKey('exception', $result);
$this->assertEquals($result['exception']->message, 'Exception - Modulo by zero');
}
/**
* Test verifying external API for a deprecated plugin type.
*
* @runInSeparateProcess
* @return void
*/
public function test_external_api_deprecated_plugintype(): void {
$this->resetAfterTest();
global $CFG;
require_once($CFG->libdir . '/upgradelib.php'); // Needed for external_update_descriptions().
// Inject the 'fake' plugin type and deprecate it.
// Note: this method of injection is required to ensure core_component fully builds all caches from the ground up,
// which is necessary to test things like class autoloading.
$this->add_full_mocked_plugintype(
plugintype: 'fake',
path: 'lib/tests/fixtures/fakeplugins/fake',
);
$this->deprecate_full_mocked_plugintype('fake');
external_update_descriptions('fake_fullfeatured');
$this->assertNotFalse(
\core_external\external_api::external_function_info('fake_fullfeatured_service_test', IGNORE_MISSING)
);
$result = \core_external\external_api::call_external_function('fake_fullfeatured_service_test', []);
$this->assertArrayHasKey('error', $result);
$this->assertFalse($result['error']);
$this->assertArrayHasKey('data', $result);
$this->assertEquals('fake_fullfeatured service result', $result['data']['result']);
}
/**
* Test verifying external API for a phase 2 deprecated (deleted) plugin type.
*
* @runInSeparateProcess
* @return void
*/
public function test_external_api_deleted_plugintype(): void {
$this->resetAfterTest();
global $CFG;
require_once($CFG->libdir . '/upgradelib.php'); // Needed for external_update_descriptions().
// Inject the 'fake' plugin type and flag it as deleted.
// Note: this method of injection is required to ensure core_component fully builds all caches from the ground up,
// which is necessary to test things like class autoloading.
$this->add_full_mocked_plugintype(
plugintype: 'fake',
path: 'lib/tests/fixtures/fakeplugins/fake',
);
$this->delete_full_mocked_plugintype('fake');
external_update_descriptions('fake_fullfeatured');
$this->assertFalse(\core_external\external_api::external_function_info('fake_fullfeatured_service_test', IGNORE_MISSING));
}
/**
* Call the get_contect_from_params methods on the api class.
*
* @return mixed
*/
protected function get_context_from_params() {
$rc = new \ReflectionClass(external_api::class);
$method = $rc->getMethod('get_context_from_params');
return $method->invokeArgs(null, func_get_args());
}
}