diff --git a/.github/workflows/loristest.yml b/.github/workflows/loristest.yml index c45e52c7870..03f9a24cfa1 100644 --- a/.github/workflows/loristest.yml +++ b/.github/workflows/loristest.yml @@ -22,6 +22,7 @@ jobs: # (see https://github.com/actions/runner-images/blob/releases/ubuntu22/20230305/images/linux/Ubuntu2204-Readme.md) # that updated the list of installed apt packages/apt repositories. That issue may disappear in future Ubuntu images. run: | + sudo apt-get update sudo apt install -y imagemagick-6-common libmagickcore-6.q16-7t64 libmagickwand-6.q16-7t64 \ libprotobuf-dev libprotobuf32t64 libprotoc32t64 protobuf-compiler cd modules/electrophysiology_browser/jsx/react-series-data-viewer/ @@ -268,6 +269,7 @@ jobs: # (see https://github.com/actions/runner-images/blob/releases/ubuntu22/20230305/images/linux/Ubuntu2204-Readme.md) # that updated the list of installed apt packages/apt repositories. That issue may disappear in future Ubuntu images. run: | + sudo apt-get update sudo apt install -y imagemagick-6-common libmagickcore-6.q16-7t64 libmagickwand-6.q16-7t64 \ libprotobuf-dev libprotobuf32t64 libprotoc32t64 protobuf-compiler cd modules/electrophysiology_browser/jsx/react-series-data-viewer/ diff --git a/htdocs/index.php b/htdocs/index.php index 7c2b3cfc2e7..37f4660d57c 100644 --- a/htdocs/index.php +++ b/htdocs/index.php @@ -29,6 +29,21 @@ // See: https://www.php.net/manual/en/session.configuration.php#ini.session.use-strict-mode ini_set('session.use_strict_mode', '1'); +// TODO: Remove this code once PHP 8.4 becomes the minimal PHP version in LORIS. +if (version_compare(PHP_VERSION, '8.4', '<')) { + // @phan-file-suppress PhanRedefineFunctionInternal + + // phpcs:ignore + function array_all(array $array, callable $callable): bool { + foreach ($array as $key => $value) { + if (!$callable($value, $key)) { + return false; + } + } + return true; + } +} + // FIXME: The code in NDB_Client should mostly be replaced by middleware. $client = new \NDB_Client; $client->initialize(); diff --git a/modules/api/php/endpoints/candidate/visit/instrument/flags.class.inc b/modules/api/php/endpoints/candidate/visit/instrument/flags.class.inc index 63ae828be63..73bf56ae392 100644 --- a/modules/api/php/endpoints/candidate/visit/instrument/flags.class.inc +++ b/modules/api/php/endpoints/candidate/visit/instrument/flags.class.inc @@ -156,27 +156,19 @@ class Flags extends Endpoint implements \LORIS\Middleware\ETagCalculator $this->_instrumentStatus = new \NDB_BVL_InstrumentStatus($loris); $this->_instrumentStatus->select($this->_instrument->commentID); - if (!$this->_instrument->determineDataEntryAllowed()) { - return new \LORIS\Http\Response\JSON\Forbidden( - 'Can not update instruments that are flagged as complete.' - ); - } - $data = json_decode((string) $request->getBody(), true); - if (!is_array($data)) { return new \LORIS\Http\Response\JSON\BadRequest( 'Invalid request' ); } - if (!$this->_instrument->validate($data)) { - return new \LORIS\Http\Response\JSON\Forbidden( - 'Could not update.' + if (!array_key_exists('Flags', $data)) { + return new \LORIS\Http\Response\JSON\BadRequest( + "Invalid request. 'Flags' missing." ); } try { - $requiredfields = [ 'Data_entry', 'Administration', @@ -229,22 +221,15 @@ class Flags extends Endpoint implements \LORIS\Middleware\ETagCalculator $this->_instrumentStatus = new \NDB_BVL_InstrumentStatus($loris); $this->_instrumentStatus->select($this->_instrument->commentID); - if (!$this->_instrument->determineDataEntryAllowed()) { - return new \LORIS\Http\Response\JSON\Forbidden( - 'Can not update instruments that are flagged as complete.' - ); - } - $data = json_decode((string) $request->getBody(), true); if (!is_array($data)) { return new \LORIS\Http\Response\JSON\BadRequest( 'Invalid request' ); } - - if (!$this->_instrument->validate($data)) { - return new \LORIS\Http\Response\JSON\Forbidden( - 'Could not update.' + if (!array_key_exists('Flags', $data)) { + return new \LORIS\Http\Response\JSON\BadRequest( + "Invalid request. 'Flags' missing." ); } diff --git a/modules/api/php/endpoints/candidate/visit/instrument/instrument.class.inc b/modules/api/php/endpoints/candidate/visit/instrument/instrument.class.inc index 4ddbac10fab..b7d8770d741 100644 --- a/modules/api/php/endpoints/candidate/visit/instrument/instrument.class.inc +++ b/modules/api/php/endpoints/candidate/visit/instrument/instrument.class.inc @@ -170,41 +170,7 @@ class Instrument extends Endpoint implements \LORIS\Middleware\ETagCalculator */ private function _handlePUT(ServerRequestInterface $request) : ResponseInterface { - if (!$this->_instrument->determineDataEntryAllowed()) { - return new \LORIS\Http\Response\JSON\Forbidden( - 'Can not update instruments that are flagged as complete.' - ); - } - - $data = json_decode((string) $request->getBody(), true); - if (!is_array($data)) { - return new \LORIS\Http\Response\JSON\BadRequest( - 'Invalid request' - ); - } - - if (!$this->_instrument->validate($data)) { - return new \LORIS\Http\Response\JSON\Forbidden( - 'Could not update.' - ); - } - - try { - $this->_instrument->clearInstrument(); - $version = $request->getAttribute('LORIS-API-Version'); - if ($version == 'v0.0.3') { - $instrumentname = $this->_instrument->testName; - $this->_instrument->_saveValues($data[$instrumentname]); - } else { - $this->_instrument->_saveValues($data['Data']); - } - $this->_instrument->score(); - $this->_instrument->updateRequiredElementsCompletedFlag(); - } catch (\Throwable $e) { - error_log($e->getMessage()); - return new \LORIS\Http\Response\JSON\InternalServerError(); - } - return (new \LORIS\Http\Response\JSON\NoContent()); + return $this->_handlePATCHAndPUT($request); } /** @@ -217,12 +183,37 @@ class Instrument extends Endpoint implements \LORIS\Middleware\ETagCalculator */ private function _handlePATCH(ServerRequestInterface $request): ResponseInterface { + return $this->_handlePATCHAndPUT($request); + } + + /** + * Handles a PUT and PATCH request. + * Mainly serves as a quick PATCH/PUT selection to validate all (PUT) or + * part (PATCH) of the sent payload. + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + private function _handlePATCHAndPUT( + ServerRequestInterface $request + ): ResponseInterface { + $requestType = $request->getMethod(); + if ($requestType !== "PATCH" && $requestType !== "PUT") { + return new \LORIS\Http\Response\JSON\BadRequest( + 'Invalid request' + ); + } + $isRequestPUT = $requestType === "PUT"; + + // data entry allowed? if (!$this->_instrument->determineDataEntryAllowed()) { return new \LORIS\Http\Response\JSON\Forbidden( 'Can not update instruments that are flagged as complete.' ); } + // get payload $data = json_decode((string) $request->getBody(), true); if (!is_array($data)) { return new \LORIS\Http\Response\JSON\BadRequest( @@ -230,27 +221,48 @@ class Instrument extends Endpoint implements \LORIS\Middleware\ETagCalculator ); } - if (!$this->_instrument->validate($data)) { + // validate given data against the instrument + $instrumentName = $this->_instrument->testName; + try { + $version = $request->getAttribute('LORIS-API-Version'); + + // extract data according to LORIS API version + $instrumentData = $version == 'v0.0.3' + ? ($data[$instrumentName] ?? []) + : ($data['Data'] ?? []); + + // validate + $this->_instrument->validate( + $instrumentData, + $isRequestPUT + ); + } catch (\LorisException $th) { + return new \LORIS\Http\Response\JSON\BadRequest( + "Could not update. {$th->getMessage()}" + ); + } catch (\Throwable $th) { + error_log($th->getMessage()); return new \LORIS\Http\Response\JSON\Forbidden( - 'Could not update.' + "Could not update." ); } + // update values try { - $version = $request->getAttribute('LORIS-API-Version'); - if ($version == 'v0.0.3') { - $instrumentname = $this->_instrument->testName; - $this->_instrument->_saveValues($data[$instrumentname]); - } else { - $this->_instrument->_saveValues($data['Data']); + // only clear if PUT + if ($isRequestPUT) { + $this->_instrument->clearInstrument(); } + + // save/score data + $this->_instrument->_saveValues($instrumentData); $this->_instrument->score(); $this->_instrument->updateRequiredElementsCompletedFlag(); } catch (\Throwable $e) { error_log($e->getMessage()); return new \LORIS\Http\Response\JSON\InternalServerError(); } - return (new \LORIS\Http\Response\JSON\NoContent()); + return new \LORIS\Http\Response\JSON\NoContent(); } /** diff --git a/php/libraries/NDB_BVL_Instrument.class.inc b/php/libraries/NDB_BVL_Instrument.class.inc index 63ff722c2f6..c4bcb015fa4 100644 --- a/php/libraries/NDB_BVL_Instrument.class.inc +++ b/php/libraries/NDB_BVL_Instrument.class.inc @@ -198,6 +198,20 @@ abstract class NDB_BVL_Instrument extends NDB_Page */ protected $selectMultipleElements = []; + /** + * Array containing all metadata elements in an instrument. + * + * @var array + * + * @access protected + */ + protected array $metadataElements = [ + "Date_taken", + "Examiner", + "Window_Difference", + "Candidate_Age", + ]; + /** * Factory generates a new instrument instance of type * $instrument, and runs the setup() method on that new @@ -2958,15 +2972,232 @@ abstract class NDB_BVL_Instrument extends NDB_Page } /** - * Validate whether the values submitted are valid + * Validate the given array keys against the instrument default structure. + * Strictly checks against "required" fields. + * Additional fields are not allowed. + * Throw a LorisException if any check does not pass. + * + * @param array $instrumentData instrument data to check * - * @param array $values an array of values submitted + * @throws \LorisException * - * @return boolean true if values are valid + * @return void */ - function validate(array $values): bool - { - return $this->determineDataEntryAllowed(); + private function _validateKeys( + array $instrumentData + ): void { + // get keys from new data + $dataKeys = array_keys($instrumentData); + + // load default instrument data keys + $defaultDictionary = $this->defaultInstanceData(); + $defaultDataKeys = array_keys($defaultDictionary); + + // filter out metadata fields, keep only instrument data fields + $defaultDataKeys = array_values( + array_filter( + $defaultDataKeys, + fn($k) => !in_array( + $k, + $this->metadataElements, + true + ) + ) + ); + + // also select all required elements only + $defaultRequiredElements = array_values( + array_filter( + $defaultDataKeys, + fn($k) => in_array( + $k, + $this->_requiredElements, + true + ) + ) + ); + + // missing required fields + $missingKeys = array_diff($defaultRequiredElements, $dataKeys); + if (!empty($missingKeys)) { + $i = implode(",", $missingKeys); + throw new \LorisException("Missing required field(s): {$i}."); + } + + // additional fields: fields outside instrument fields + // Note: additional fields are not an issue with JSON instruments + // but only with instruments having their own table. + $additionalKeys = array_diff($dataKeys, $defaultDataKeys); + if (!empty($additionalKeys)) { + $i = implode(",", $additionalKeys); + throw new \LorisException("Additional field(s) not permitted: {$i}."); + } + } + + /** + * Validate the given array values against the instrument dictionary items. + * Throw a LorisException if any check does not pass. + * + * @param array $instrumentData instrument data to check + * @param bool $checkAllValues tells if all instrument field values should + * be checked (PUT) instead of part of them + * (PATCH) + * + * @throws \LorisException + * + * @return void + */ + private function _validateValues( + array $instrumentData, + bool $checkAllValues = false + ): void { + // Get date format from system configuration + $config = $this->loris->getConfiguration(); + $dateFormat = $config->getSetting('dateDisplayFormat'); + + // Date parser function + $getDateTime = fn($s, $f) => DateTime::createFromFormat($f, $s); + + // Type check functions with string compatibility + $isString = fn($f) => is_scalar($f) && is_string("{$f}"); + $isBoolean = fn($f) => + is_bool($f) + || (is_string($f) + && in_array(strtolower($f), ['true', 'false', '0', '1'], true)) + || (is_numeric($f) && ($f == 0 || $f == 1)); + $isInteger = fn($f) => + is_int($f) + || (is_string($f) && preg_match('/^\d+$/', $f)) + || (is_float($f) && floor($f) == $f); + $isFloat = fn($f) => is_numeric($f) && is_float((float)$f + 0); + $isDuration = fn($f) => $isInteger($f) && (int)$f >= 0; + $isDate = fn($f, $fmt) => + ($dt = $getDateTime($f, $fmt)) !== false && $dt->format($fmt) === $f; + $isTime = fn($f, $fmt) => + ($dt = DateTime::createFromFormat( + "Y-m-d {$fmt}", + "2000-01-01 {$f}" + ) + ) !== false && $dt->format($fmt) === $f; + + // Validate each field in the data dictionary + foreach ($this->getDataDictionary() as $dictionaryItem) { + $fieldName = $dictionaryItem->fieldname; + + // Skip fields that are not present in PATCH input + if (!$checkAllValues && !array_key_exists($fieldName, $instrumentData)) { + continue; + } + + // Skip metadata/system fields + if (in_array($fieldName, $this->metadataElements, true)) { + continue; + } + + // Get expected type object and field value + $expectedType = $dictionaryItem->getDataType(); + $fieldToCheck = $instrumentData[$fieldName]; + + $t = $expectedType; + + // Normalize string values (e.g., trim spaces) + if (is_string($fieldToCheck)) { + $fieldToCheck = trim($fieldToCheck); + } + + // Get enumeration options if applicable + $optionKeys = $expectedType instanceof \LORIS\Data\Types\Enumeration + ? $expectedType->getOptions() + : null; + + // Check if this field supports multiple selection + $isMultiEnum = in_array($fieldName, $this->selectMultipleElements, true); + + // Validate the value based on its expected type + + $f = $fieldToCheck; + $d = $dateFormat; + + $isValid = match (true) { + $t instanceof \LORIS\Data\Types\StringType => $isString($f), + $t instanceof \LORIS\Data\Types\URI => $isString($f), + $t instanceof \LORIS\Data\Types\BooleanType => $isBoolean($f), + $t instanceof \LORIS\Data\Types\IntegerType => $isInteger($f), + $t instanceof \LORIS\Data\Types\DecimalType => $isFloat($f), + $t instanceof \LORIS\Data\Types\DateType => $isDate($f, $d), + $t instanceof \LORIS\Data\Types\TimeType => $isTime($f, $d), + $t instanceof \LORIS\Data\Types\Duration => $isDuration($f), + $t instanceof \LORIS\Data\Types\Enumeration => $isString($f) && ( + !$isMultiEnum + ? in_array($fieldToCheck, $optionKeys, true) + : array_all( + explode("{@}", $fieldToCheck), + fn($v) => in_array(trim($v), $optionKeys, true) + ) + ), + default => throw new \LorisException( + "Unknown type '" . get_class($expectedType) . + "' for field: {$fieldToCheck}" + ) + }; + + // If validation failed, prepare an error message + if (!$isValid) { + $expectedFormat = match (true) { + $expectedType instanceof \LORIS\Data\Types\DateType => + " (format: '{$dateFormat}')", + $expectedType instanceof \LORIS\Data\Types\TimeType => + " (format: 'HH:mm:ss')", + $expectedType instanceof \LORIS\Data\Types\Enumeration => + ", possible answers: '" + .implode("','", $optionKeys ?? []) . "'", + default => "" + }; + + // Add multiselect info if applicable + if ($isMultiEnum && $t instanceof \LORIS\Data\Types\Enumeration) { + $expectedFormat .= " with delimiter '{@}'"; + } + + // Compose the full validation error message + $multi = $isMultiEnum ? "multi-" : ""; + $msg = "Field not valid: {$fieldName}. "; + $msg .= "Expected: {$multi}" + . basename(str_replace('\\', '/', get_class($t))); + $msg .= "{$expectedFormat}"; + + throw new \LorisException($msg); + } + } + } + + /** + * Validate whether the data submitted are valid against this instrument. + * Checks both keys and values. Throws a LorisException if any test fails. + * + * @param array $dataToCheck an array of values submitted + * @param bool $checkAllValues tells if all instrument field values should + * be checked (PUT) instead of part of them + * (PATCH) + * + * @throws \LorisException + * + * @return void + */ + public function validate( + array $dataToCheck, + bool $checkAllValues = false + ): void { + // data to check even exist + if (empty($dataToCheck)) { + throw new \LorisException("No data provided."); + } + + // validate the keys against the instrument dictionary entries + $this->_validateKeys($dataToCheck); + + // validate the values against the instrument dictionary entries + $this->_validateValues($dataToCheck, $checkAllValues); } /** diff --git a/raisinbread/test/api/LorisApiInstrumentsTest.php b/raisinbread/test/api/LorisApiInstrumentsTest.php index 01dc5eefcf2..a547b54240e 100644 --- a/raisinbread/test/api/LorisApiInstrumentsTest.php +++ b/raisinbread/test/api/LorisApiInstrumentsTest.php @@ -219,7 +219,9 @@ public function testPatchCandidatesCandidVisitInstrumentsInstrument(): void { $json = [ 'Data' => [ - 'UserID' => "2" + 'consent' => "yes", + 'testText' => 'test text', + 'testCheckbox' => 'true' ] ]; $response = $this->client->request( @@ -247,7 +249,9 @@ public function testPutCandidatesCandidVisitInstrumentsInstrument(): void { $json = [ 'Data' => [ - 'UserID' => "2" + 'consent' => "yes", + 'testText' => 'test text', + 'testCheckbox' => 'true' ] ]; $response = $this->client->request( @@ -312,7 +316,7 @@ public function testPatchCandidatesCandidVisitInstrumentsInstrumentFlags(): void 'Flags' => [ 'Data_entry' => 'Complete', 'Administration' => 'All', - 'Validity' => 'Invalid' + 'Validity' => 'Invalid' ] ]; $response = $this->client->request( @@ -387,7 +391,7 @@ public function testPutCandidatesCandidVisitInstrumentsInstrumentFlags(): void 'Flags' => [ 'Data_entry' => 'Complete', 'Administration' => 'Partial', - 'Validity' => 'Questionable' + 'Validity' => 'Questionable' ] ]; $response = $this->client->request( @@ -470,7 +474,7 @@ public function testPatchCandidatesCandidVisitInstrumentsInstrumentDde(): void 'Instrument' => $this->instrumentTest ], 'Data' => [ - 'UserID' => "2" + 'consent' => "yes" ] ]; $response = $this->client->request( @@ -487,40 +491,6 @@ public function testPatchCandidatesCandidVisitInstrumentsInstrumentDde(): void $this->assertNotEmpty($body); } - /** - * Tests the HTTP PUT request for the - * endpoint /candidates/{candid}/{visit}/instruments/{instrument} - * - * @return void - */ - public function testPutCandidatesCandidVisitInstrumentsInstrumentDde(): void - { - $json = [ - 'Meta' => [ - 'CandID' => $this->candidTest, - 'Visit' => $this->visitTest, - 'DDE' => true, - 'Instrument' => $this->instrumentTest - ], - 'Data' => [ - 'UserID' => "2" - ] - ]; - $response = $this->client->request( - 'PUT', - "candidates/$this->candidTest/$this->visitTest/instruments/$this->instrumentTest/dde", - [ - 'headers' => $this->headers, - 'json' => $json - ] - ); - $this->assertEquals(204, $response->getStatusCode()); - // Verify the endpoint has a body - $body = $response->getBody(); - $this->assertNotEmpty($body); - } - - /** * Tests the HTTP GET request for the * endpoint /candidates/{candid}/{visit}/instruments/{instruments}/dde/flags @@ -569,7 +539,7 @@ public function testPatchCandidVisitInstrumentsInstrumentDdeFlags(): void 'Flags' => [ 'Data_entry' => 'Complete', 'Administration' => 'All', - 'Validity' => 'Valid' + 'Validity' => 'Valid' ] ]; $response = $this->client->request( @@ -624,7 +594,7 @@ public function testPutCandidVisitInstrumentsInstrumentDdeFlags(): void 'Flags' => [ 'Data_entry' => 'Complete', 'Administration' => 'All', - 'Validity' => 'Valid' + 'Validity' => 'Valid' ] ]; $response = $this->client->request( diff --git a/raisinbread/test/api/LorisApiInstruments_v0_0_3_Test.php b/raisinbread/test/api/LorisApiInstruments_v0_0_3_Test.php index a18fe1357d4..4df079e5bbf 100644 --- a/raisinbread/test/api/LorisApiInstruments_v0_0_3_Test.php +++ b/raisinbread/test/api/LorisApiInstruments_v0_0_3_Test.php @@ -137,7 +137,7 @@ public function testPatchCandidatesCandidVisitInstrumentsInstrument(): void { $json = [ $this->instrumentTest => [ - 'UserID' => "2" + 'consent' => "yes" ] ]; $response = $this->client->request( @@ -164,7 +164,9 @@ public function testPutCandidatesCandidVisitInstrumentsInstrument(): void { $json = [ $this->instrumentTest => [ - 'UserID' => "2" + 'consent' => "yes", + 'testText' => 'test', + 'testCheckbox' => 'true' ] ]; $response = $this->client->request( @@ -248,7 +250,7 @@ public function testPatchCandidatesCandidVisitInstrumentsInstrumentFlags(): void // Test that it should be forbidden to modify an instrument that is flagged as Complete $json = [ $this->instrumentTest => [ - 'UserID' => "2" + 'consent' => "2" ] ]; $response = $this->client->request( @@ -268,7 +270,7 @@ public function testPatchCandidatesCandidVisitInstrumentsInstrumentFlags(): void // This will test that it should be forbidden to modify an instrument that is flagged as Complete $json = [ $this->instrumentTest => [ - 'UserID' => "2" + 'consent' => "2" ] ]; $response = $this->client->request( @@ -323,7 +325,7 @@ public function testPutCandidatesCandidVisitInstrumentsInstrumentFlags(): void // This will test that it should be forbidden to modify an instrument that is flagged as Complete $json = [ $this->instrumentTest => [ - 'UserID' => "2" + 'consent' => "2" ] ]; $response = $this->client->request( @@ -387,7 +389,7 @@ public function testPatchCandidatesCandidVisitInstrumentsInstrumentDde(): void 'Instrument' => $this->instrumentTest ], $this->instrumentTest => [ - 'UserID' => "2" + 'testText' => "test Text" ] ]; $response = $this->client->request( @@ -420,7 +422,9 @@ public function testPutCandidatesCandidVisitInstrumentsInstrumentDde(): void 'Instrument' => $this->instrumentTest ], $this->instrumentTest => [ - 'UserID' => "2" + 'consent' => "yes", + 'testText' => 'test text', + 'testCheckbox' => 'true' ] ]; $response = $this->client->request( diff --git a/test/unittests/NDB_BVL_Instrument_Test.php b/test/unittests/NDB_BVL_Instrument_Test.php index 8af424b0406..c400592aa90 100644 --- a/test/unittests/NDB_BVL_Instrument_Test.php +++ b/test/unittests/NDB_BVL_Instrument_Test.php @@ -37,7 +37,7 @@ class NDB_BVL_Instrument_Test extends TestCase /** * The instrument (or instrument mock) being tested. * - * @var \NDB_BVL_Instrument + * @var \PHPUnit\Framework\MockObject\MockObject&\NDB_BVL_Instrument */ private $_instrument; @@ -1692,24 +1692,256 @@ function testClearInstrument() /** * Test that determineDataEntryAllowed returns true if the Data_entry is anything - * but 'Complete' and returns false if Data_entry is 'Complete. Test that - * validate simply calls determineDataEntryAllowed and has the same output. + * but 'Complete' and returns false if Data_entry is 'Complete. * * @covers NDB_BVL_Instrument::determineDataEntryAllowed - * @covers NDB_BVL_Instrument::validate + * * @return void */ - function testDetermineDataEntryAllowed() + public function testDetermineDataEntryAllowed() { $this->_setUpMockDB(); $this->_setTableData(); $this->_instrument->commentID = 'commentID1'; $this->_instrument->table = 'medical_history'; $this->assertTrue($this->_instrument->determineDataEntryAllowed()); - $this->assertTrue($this->_instrument->validate(['value1'])); $this->_instrument->commentID = 'commentID2'; $this->assertFalse($this->_instrument->determineDataEntryAllowed()); - $this->assertFalse($this->_instrument->validate(['value1'])); + } + + /** + * Test that check validation method with received data. + * Empty array parameter case. + * + * @covers NDB_BVL_Instrument::validate + * + * @return void + */ + public function testValidateEmptyParameter(): void + { + $this->_setUpMockDB(); + $this->_setTableData(); + $this->_instrument->table = 'medical_history'; + $this->_instrument->commentID = 'commentID1'; + $this->expectException("LorisException"); + $this->expectExceptionMessage("No data provided."); + $this->_instrument->validate([]); + } + /** + * Test that check validation method with received data. + * Keys validation case - missing keys + * + * @covers NDB_BVL_Instrument::validate + * + * @phan-suppress PhanUndeclaredProperty + * @phan-suppress PhanUndeclaredMethod + * + * @return void + */ + public function testValidateMissingRequiredKeys(): void + { + $this->_setUpMockDB(); + $this->_setTableData(); + + /** + * Phan-var + * + * @phan-var \NDB_BVL_Instrument $instrument + */ + + $instrument = $this->getMockBuilder( + \NDB_BVL_Instrument::class + )->disableOriginalConstructor() + ->onlyMethods( + ["getFullName", + "getSubtestList", + "getDataDictionary" + ] + )->getMock(); + + $instrument->method('getDataDictionary')->willReturn( + [ + (object)['fieldname' => 'arthritis'], + (object)['fieldname' => 'hypertension'], + (object)['fieldname' => 'concussion_or_head_trauma'], + ] + ); + + // Stub abstract methods so PHPUnit can instantiate the mock + $instrument->method('getFullName')->willReturn('Test Instrument'); + $instrument->method('getSubtestList')->willReturn([]); + // phan-suppress-next-line PhanUndeclaredProperty + $instrument->_requiredElements = ['arthritis', 'hypertension']; + + // Now call validate with data missing required fields + $this->expectException(\LorisException::class); + $this->expectExceptionMessageMatches( + '/arthritis.*hypertension|hypertension.*arthritis/' + ); + + // phan-suppress-next-line PhanUndeclaredMethod + $instrument->validate( + [ + 'concussion_or_head_trauma' => null, + ] + ); + } + + /** + * Test that check validation method with received data. + * Keys validation case - additional keys. + * + * @covers NDB_BVL_Instrument::validate + * + * @return void + */ + public function testValidateAdditionalKeys(): void + { + $this->_setUpMockDB(); + $this->_setTableData(); + $this->_instrument->table = 'medical_history'; + $this->_instrument->commentID = 'commentID1'; + + // using factory to init the instrument + // not doing doe snot pass the comparison + // (not the full list of fields) + + // data - complete keys + empty values + $instrumentQuestions = [ + // p1 + "arthritis" => null, // required + "arthritis_age" => null, + "pulmonary_issues" => null, + "pulmonary_issues_specific" => null, + // p2 + "hypertension" => null, // required + "hypertension_while_pregnant" => null, + "hypertension_while_pregnant_age" => null, + // p3 + "concussion_or_head_trauma" => null, // required + "concussion_1_description" => null, + "concussion_1_hospitalized" => null, + "concussion_1_age" => null, + "concussion_2_description" => null, + "concussion_2_hospitalized" => null, + "concussion_2_age" => null, + "concussion_3_description" => null, + "concussion_3_hospitalized" => null, + "concussion_3_age" => null, + "current_concussion_symptoms" => null, + ]; + + // additional keys, adding two new unexpected keys + $instrumentQuestions["aaa"] = 123; + $instrumentQuestions["bbb"] = "a text"; + + // expect error on these 2 additional fields + $this->expectException("LorisException"); + $this->expectExceptionMessageMatches( + '/Additional field\(s\) not permitted:.*aaa,bbb/' + ); + // phan-suppress-next-line PhanUndeclaredMethod + $this->_instrument->validate($instrumentQuestions); + } + + /** + * Test that check validation method with received data. + * Values validation case. + * + * @covers NDB_BVL_Instrument::validate + * + * @phan-suppress PhanUndeclaredProperty + * @phan-suppress PhanUndeclaredMethod + * @return void + */ + public function testValidateValues(): void + { + $this->_setUpMockDB(); + $this->_setTableData(); + + $this->_instrument = $this->getMockBuilder( + \NDB_BVL_Instrument::class + ) + ->disableOriginalConstructor() + ->onlyMethods(["getFullName", "getSubtestList", "getDataDictionary"]) + ->getMock(); + // phan-suppress-next-line PhanUndeclaredProperty + $this->_instrument->_requiredElements = [ + 'arthritis', 'hypertension', 'concussion_or_head_trauma' + ]; + + $instrumentQuestions = [ + // p1 + "arthritis" => 'string', // required + "arthritis_age" => 'string', + "pulmonary_issues" => 'string', + "pulmonary_issues_specific" => 'string', + // p2 + "hypertension" => 'string', // required + "hypertension_while_pregnant" => 'string', + "hypertension_while_pregnant_age" => 'string', + // p3 + "concussion_or_head_trauma" => 'string', // required + "concussion_1_description" => 'string', + "concussion_1_hospitalized" => 'string', + "concussion_1_age" => 'string', + "concussion_2_description" => 'string', + "concussion_2_hospitalized" => 'string', + "concussion_2_age" => 'string', + "concussion_3_description" => 'string', + "concussion_3_hospitalized" => 'string', + "concussion_3_age" => 'string', + "current_concussion_symptoms" => 'string', + ]; + + $this->_instrument->method('getDataDictionary')->willReturn( + array_map( + fn($field) => new class($field) { + public string $fieldname; + /** + * Constructor. + * + * @param {string} $field field + * + * @return void + */ + public function __construct($field) + { + $this->fieldname = $field; + } + /** + * GetDataType. + * + * @return \LORIS\Data\Types\StringType + */ + public function getDataType() + { + return new \LORIS\Data\Types\StringType(); + + } + }, + array_keys($instrumentQuestions) + ) + ); + // phan-suppress-next-line PhanUndeclaredProperty + $this->_instrument->table = 'medical_history'; + // phan-suppress-next-line PhanUndeclaredProperty + $this->_instrument->commentID = 'commentID1'; + + $mockConfig = $this->createMock(\NDB_Config::class); + $mockConfig->method('getSetting')->with( + 'dateDisplayFormat' + )->willReturn('Y-m-d'); + + $mockLoris = $this->createMock(\LORIS\LorisInstance::class); + $mockLoris->method('getConfiguration')->willReturn($mockConfig); + + $ref = new \ReflectionProperty(get_class($this->_instrument), 'loris'); + $ref->setAccessible(true); + $ref->setValue($this->_instrument, $mockLoris); + // phan-suppress-next-line PhanUndeclaredMethod + $this->_instrument->validate($instrumentQuestions); + } /**