diff --git a/module/VuFind/src/VuFind/ILS/Driver/Unicorn.php b/module/VuFind/src/VuFind/ILS/Driver/Unicorn.php index 023353ff304..650b6386d4d 100644 --- a/module/VuFind/src/VuFind/ILS/Driver/Unicorn.php +++ b/module/VuFind/src/VuFind/ILS/Driver/Unicorn.php @@ -1286,18 +1286,6 @@ protected function formatDateTime($time) return $dateTimeString; } - /** - * Convert the given ISO-8859-1 string to UTF-8 if it is not already UTF-8. - * - * @param string $s The string to convert. - * - * @return string The input string converted to UTF-8 - */ - protected function toUTF8($s) - { - return (mb_detect_encoding($s, 'UTF-8') == 'UTF-8') ? $s : utf8_encode($s); - } - /** * Given a location field, return the values relevant to VuFind. * diff --git a/module/VuFind/src/VuFind/ILS/Driver/Voyager.php b/module/VuFind/src/VuFind/ILS/Driver/Voyager.php index 26a9829a04e..af7509e114f 100644 --- a/module/VuFind/src/VuFind/ILS/Driver/Voyager.php +++ b/module/VuFind/src/VuFind/ILS/Driver/Voyager.php @@ -492,7 +492,7 @@ protected function getStatusData($sqlRows) 'status_array' => [$row['STATUS']], 'location' => $row['TEMP_LOCATION'] > 0 ? $this->getLocationName($row['TEMP_LOCATION']) - : utf8_encode($row['LOCATION']), + : $this->utf8Encode($row['LOCATION']), 'reserve' => $row['ON_RESERVE'], 'callnumber' => $row['CALLNUMBER'], 'item_sort_seq' => $row['ITEM_SEQUENCE_NUMBER'], @@ -846,7 +846,7 @@ protected function getPurchaseHistoryData($id) $raw = $processed = []; // Collect raw data: while ($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) { - $raw[] = $row['MFHD_ID'] . '||' . utf8_encode($row['ENUMCHRON']); + $raw[] = $row['MFHD_ID'] . '||' . $this->utf8Encode($row['ENUMCHRON']); } // Deduplicate data and format it: foreach (array_unique($raw) as $current) { @@ -986,7 +986,7 @@ protected function getLocationName($id) $bind = ['id' => $id]; $sqlStmt = $this->executeSQL($sql, $bind); $sqlRow = $sqlStmt->fetch(PDO::FETCH_ASSOC); - $cache[$id] = utf8_encode($sqlRow['LOCATION']); + $cache[$id] = $this->utf8Encode($sqlRow['LOCATION']); } return $cache[$id]; @@ -1008,7 +1008,7 @@ protected function processHoldingRow($sqlRow) 'status' => $sqlRow['STATUS'], 'location' => $sqlRow['TEMP_LOCATION'] > 0 ? $this->getLocationName($sqlRow['TEMP_LOCATION']) - : utf8_encode($sqlRow['LOCATION']), + : $this->utf8Encode($sqlRow['LOCATION']), 'reserve' => $sqlRow['ON_RESERVE'], 'callnumber' => $sqlRow['CALLNUMBER'], 'barcode' => $sqlRow['ITEM_BARCODE'], @@ -1110,7 +1110,7 @@ protected function processHoldingData($data, $id, $patron = null) $holding[$i] += [ 'availability' => $availability['available'], 'enumchron' => isset($row['ITEM_ENUM']) - ? utf8_encode($row['ITEM_ENUM']) : null, + ? $this->utf8Encode($row['ITEM_ENUM']) : null, 'duedate' => $this->processHoldingDueDate($row), 'number' => $number, 'requests_placed' => $requests_placed, @@ -1293,7 +1293,7 @@ public function patronLogin($username, $login) } try { - $bindUsername = strtolower(utf8_decode($username)); + $bindUsername = strtolower(mb_convert_encoding($username, 'ISO-8859-1', 'UTF-8')); $compareLogin = mb_strtolower($login, 'UTF-8'); $sqlStmt = $this->executeSQL($sql, [':username' => $bindUsername]); @@ -1301,10 +1301,10 @@ public function patronLogin($username, $login) // rows just to be safe while ($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) { $primary = null !== $row['LOGIN'] - ? mb_strtolower(utf8_encode($row['LOGIN']), 'UTF-8') + ? mb_strtolower($this->utf8Encode($row['LOGIN']), 'UTF-8') : null; $fallback = $fallbackLoginField && null === $row['LOGIN'] - ? mb_strtolower(utf8_encode($row['FALLBACK_LOGIN']), 'UTF-8') + ? mb_strtolower($this->utf8Encode($row['FALLBACK_LOGIN']), 'UTF-8') : null; if ( @@ -1314,9 +1314,9 @@ public function patronLogin($username, $login) && $fallback == $compareLogin) ) { return [ - 'id' => utf8_encode($row['PATRON_ID']), - 'firstname' => utf8_encode($row['FIRST_NAME']), - 'lastname' => utf8_encode($row['LAST_NAME']), + 'id' => $this->utf8Encode($row['PATRON_ID']), + 'firstname' => $this->utf8Encode($row['FIRST_NAME']), + 'lastname' => $this->utf8Encode($row['LAST_NAME']), 'cat_username' => $username, 'cat_password' => $login, // There's supposed to be a getPatronEmailAddress stored @@ -1474,10 +1474,10 @@ protected function processMyTransactionsData($sqlRow, $patron = false) $transaction = [ 'id' => $sqlRow['BIB_ID'], 'item_id' => $sqlRow['ITEM_ID'], - 'barcode' => utf8_encode($sqlRow['ITEM_BARCODE']), + 'barcode' => $this->utf8Encode($sqlRow['ITEM_BARCODE']), 'duedate' => $dueDate, 'dueStatus' => $dueStatus, - 'volume' => str_replace('v.', '', utf8_encode($sqlRow['ITEM_ENUM'])), + 'volume' => str_replace('v.', '', $this->utf8Encode($sqlRow['ITEM_ENUM'])), 'publication_year' => $sqlRow['YEAR'], 'title' => empty($sqlRow['TITLE_BRIEF']) ? $sqlRow['TITLE'] : $sqlRow['TITLE_BRIEF'], @@ -1495,7 +1495,7 @@ protected function processMyTransactionsData($sqlRow, $patron = false) } if (!empty($this->config['Loans']['display_borrowing_location'])) { $transaction['borrowingLocation'] - = utf8_encode($sqlRow['BORROWING_LOCATION']); + = $this->utf8Encode($sqlRow['BORROWING_LOCATION']); } return $transaction; @@ -1619,7 +1619,7 @@ protected function processFinesData($sqlRow) } return ['amount' => $sqlRow['FINE_FEE_AMOUNT'], - 'fine' => utf8_encode($sqlRow['FINE_FEE_DESC']), + 'fine' => $this->utf8Encode($sqlRow['FINE_FEE_DESC']), 'balance' => $sqlRow['FINE_FEE_BALANCE'], 'createdate' => $createDate, 'checkout' => $chargeDate, @@ -1765,7 +1765,7 @@ protected function processMyHoldsData($sqlRow) 'available' => $available, 'reqnum' => $sqlRow['HOLD_RECALL_ID'], 'item_id' => $sqlRow['ITEM_ID'], - 'volume' => str_replace('v.', '', utf8_encode($sqlRow['ITEM_ENUM'])), + 'volume' => str_replace('v.', '', $this->utf8Encode($sqlRow['ITEM_ENUM'])), 'publication_year' => $sqlRow['YEAR'], 'title' => empty($sqlRow['TITLE_BRIEF']) ? $sqlRow['TITLE'] : $sqlRow['TITLE_BRIEF'], @@ -1957,13 +1957,13 @@ protected function processMyStorageRetrievalRequestsData($sqlRow) return [ 'id' => $sqlRow['BIB_ID'], - 'status' => utf8_encode($sqlRow['STATUS_DESC']), + 'status' => $this->utf8Encode($sqlRow['STATUS_DESC']), 'statusDate' => $statusDate, 'location' => $this->getLocationName($sqlRow['PICKUP_LOCATION_ID']), 'create' => $createDate, 'processed' => $processedDate, 'expire' => $expireDate, - 'reply' => utf8_encode($sqlRow['REPLY_NOTE']), + 'reply' => $this->utf8Encode($sqlRow['REPLY_NOTE']), 'available' => $available, 'canceled' => $sqlRow['STATUS'] == 7 ? $statusDate : false, 'reqnum' => $sqlRow['CALL_SLIP_ID'], @@ -1971,10 +1971,10 @@ protected function processMyStorageRetrievalRequestsData($sqlRow) 'volume' => str_replace( 'v.', '', - utf8_encode($sqlRow['ITEM_ENUM']) + $this->utf8Encode($sqlRow['ITEM_ENUM']) ), - 'issue' => utf8_encode($sqlRow['ITEM_CHRON']), - 'year' => utf8_encode($sqlRow['ITEM_YEAR']), + 'issue' => $this->utf8Encode($sqlRow['ITEM_CHRON']), + 'year' => $this->utf8Encode($sqlRow['ITEM_YEAR']), 'title' => empty($sqlRow['TITLE_BRIEF']) ? $sqlRow['TITLE'] : $sqlRow['TITLE_BRIEF'], ]; @@ -2042,23 +2042,23 @@ public function getMyProfile($patron) $patron = []; while ($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) { if (!empty($row['FIRST_NAME'])) { - $patron['firstname'] = utf8_encode($row['FIRST_NAME']); + $patron['firstname'] = $this->utf8Encode($row['FIRST_NAME']); } if (!empty($row['LAST_NAME'])) { - $patron['lastname'] = utf8_encode($row['LAST_NAME']); + $patron['lastname'] = $this->utf8Encode($row['LAST_NAME']); } if (!empty($row['PHONE_NUMBER'])) { if ($primaryPhoneType === $row['PHONE_DESC']) { - $patron['phone'] = utf8_encode($row['PHONE_NUMBER']); + $patron['phone'] = $this->utf8Encode($row['PHONE_NUMBER']); } elseif ($mobilePhoneType === $row['PHONE_DESC']) { - $patron['mobile_phone'] = utf8_encode($row['PHONE_NUMBER']); + $patron['mobile_phone'] = $this->utf8Encode($row['PHONE_NUMBER']); } } if (!empty($row['PATRON_GROUP_NAME'])) { - $patron['group'] = utf8_encode($row['PATRON_GROUP_NAME']); + $patron['group'] = $this->utf8Encode($row['PATRON_GROUP_NAME']); } $validator = new EmailAddressValidator(); - $addr1 = utf8_encode($row['ADDRESS_LINE1']); + $addr1 = $this->utf8Encode($row['ADDRESS_LINE1']); if ($validator->isValid($addr1)) { $patron['email'] = $addr1; } elseif (!isset($patron['address1'])) { @@ -2066,16 +2066,16 @@ public function getMyProfile($patron) $patron['address1'] = $addr1; } if (!empty($row['ADDRESS_LINE2'])) { - $patron['address2'] = utf8_encode($row['ADDRESS_LINE2']); + $patron['address2'] = $this->utf8Encode($row['ADDRESS_LINE2']); } if (!empty($row['ZIP_POSTAL'])) { - $patron['zip'] = utf8_encode($row['ZIP_POSTAL']); + $patron['zip'] = $this->utf8Encode($row['ZIP_POSTAL']); } if (!empty($row['CITY'])) { - $patron['city'] = utf8_encode($row['CITY']); + $patron['city'] = $this->utf8Encode($row['CITY']); } if (!empty($row['COUNTRY'])) { - $patron['country'] = utf8_encode($row['COUNTRY']); + $patron['country'] = $this->utf8Encode($row['COUNTRY']); } } } @@ -2638,4 +2638,16 @@ protected function executeSQL($sql, $bind = []) return $sqlStmt; } + + /** + * Convert string from ISO 8859-1 into UTF-8 + * + * @param string $iso88591 String to convert + * + * @return string + */ + protected function utf8Encode(string $iso88591): string + { + return mb_convert_encoding($iso88591, 'UTF-8', 'ISO-8859-1'); + } } diff --git a/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php b/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php index a818ce8d136..5ed7d5a1568 100644 --- a/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php +++ b/module/VuFind/src/VuFind/ILS/Driver/VoyagerRestful.php @@ -747,7 +747,7 @@ public function getPickUpLocations($patron = false, $holdDetails = null) while ($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) { $pickResponse[] = [ 'locationID' => $row['LOCATION_ID'], - 'locationDisplay' => utf8_encode($row['LOCATION_NAME']), + 'locationDisplay' => $this->utf8Encode($row['LOCATION_NAME']), ]; } } @@ -1007,7 +1007,7 @@ function ($s) { while ($row = $sqlStmt->fetch(PDO::FETCH_ASSOC)) { $results[] = [ 'id' => $row['GROUP_ID'], - 'name' => utf8_encode($row['GROUP_NAME']), + 'name' => $this->utf8Encode($row['GROUP_NAME']), ]; } diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/VoyagerRestfulTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/VoyagerRestfulTest.php index 71fe6c72414..0ddf55dec4e 100644 --- a/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/VoyagerRestfulTest.php +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/VoyagerRestfulTest.php @@ -29,6 +29,8 @@ namespace VuFindTest\ILS\Driver; +use PDOStatement; +use PHPUnit\Framework\MockObject\MockObject; use VuFind\ILS\Driver\VoyagerRestful; /** @@ -42,6 +44,22 @@ */ class VoyagerRestfulTest extends \VuFindTest\Unit\ILSDriverTestCase { + /** + * Default configuration for driver + * + * @var array + */ + protected $defaultConfig = [ + 'Catalog' => ['database' => 'foo'], + 'WebServices' => [ + 'host' => 'foo', + 'port' => 1234, + 'app' => 'bar', + 'dbKey' => 'fake', + 'patronHomeUbId' => 'baz', + ], + ]; + /** * Standard setup method. * @@ -51,4 +69,127 @@ public function setUp(): void { $this->driver = new VoyagerRestful(new \VuFind\Date\Converter()); } + + /** + * Test encoding conversion in getPickupLocations() + * + * @return void + */ + public function testGetPickupLocationsConversion(): void + { + $location = 'Tést'; + + // Create a mock SQL response + $mockResult = $this->createMock(PDOStatement::class); + $mockResult->method('fetch')->willReturnCallback(function () use ($location) { + static $called = false; + if ($called) { + return null; + } + $called = true; + return [ + 'LOCATION_ID' => 1, + 'LOCATION_NAME' => mb_convert_encoding($location, 'ISO-8859-1', 'UTF-8'), + ]; + }); + + // Use an anonymous class to override the executeSQL method for mocking purposes: + $driver = $this->getDriverWithMockSqlResponse($mockResult); + $this->assertEquals( + [ + [ + 'locationID' => 1, + 'locationDisplay' => $location, + ], + ], + $driver->getPickUpLocations() + ); + } + + /** + * Test that request groups are disabled by default. + * + * @return void + */ + public function testGetRequestGroupsDefaultBehavior(): void + { + $this->assertFalse($this->driver->getRequestGroups(1, [])); + } + + /** + * Test encoding conversion in getRequestGroups() + * + * @return void + */ + public function testGetRequestGroupsConversion(): void + { + $name = 'Tést'; + + // Create a mock SQL response + $mockResult = $this->createMock(PDOStatement::class); + $mockResult->method('fetch')->willReturnCallback(function () use ($name) { + static $called = false; + if ($called) { + return null; + } + $called = true; + return [ + 'GROUP_ID' => 1, + 'GROUP_NAME' => mb_convert_encoding($name, 'ISO-8859-1', 'UTF-8'), + ]; + }); + + // Use an anonymous class to override the executeSQL method for mocking purposes: + $driver = $this->getDriverWithMockSqlResponse($mockResult); + // Enable request groups + $driver->setConfig($this->defaultConfig + [ + 'Holds' => ['extraHoldFields' => 'requestGroup'], + ]); + $driver->init(); + $this->assertEquals( + [ + [ + 'id' => 1, + 'name' => $name, + ], + ], + $driver->getRequestGroups(1, []) + ); + } + + /** + * Get a VoyagerRestful driver customized to return a mock SQL response. + * + * @param MockObject&PDOStatement $mockResult Mock result to return from executeSQL + * + * @return VoyagerRestful + */ + protected function getDriverWithMockSqlResponse(MockObject&PDOStatement $mockResult): VoyagerRestful + { + return new class ($mockResult) extends VoyagerRestful { + /** + * Constructor + * + * @param MockObject&PDOStatement $mockResult Mock result to return from executeSQL + */ + public function __construct(protected MockObject&PDOStatement $mockResult) + { + parent::__construct(new \VuFind\Date\Converter()); + } + + /** + * Execute an SQL query + * + * @param string|array $sql SQL statement (string or array that includes + * bind params) + * @param array $bind Bind parameters (if $sql is string) + * + * @return PDOStatement + */ + protected function executeSQL($sql, $bind = []) + { + return $this->mockResult; + } + }; + } } diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/VoyagerTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/VoyagerTest.php index 130fe0e0850..9aefa3031bc 100644 --- a/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/VoyagerTest.php +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/ILS/Driver/VoyagerTest.php @@ -29,6 +29,8 @@ namespace VuFindTest\ILS\Driver; +use PDOStatement; +use PHPUnit\Framework\MockObject\MockObject; use VuFind\ILS\Driver\Voyager; /** @@ -110,4 +112,79 @@ public function testMarcParsing(): void $results ); } + + /** + * Test that patron usernames are correctly encoded during login. + * + * @return void + */ + public function testUsernameEncodingDuringLogin(): void + { + // Create a mock SQL response + $mockResult = $this->createMock(PDOStatement::class); + $mockResult->method('fetch')->willReturn(null); + + $driver = $this->getDriverWithMockSqlResponse($mockResult); + $this->assertNull($driver->patronLogin('Tést', 'foo')); + $this->assertEquals([':username' => mb_convert_encoding('tést', 'ISO-8859-1', 'UTF-8')], $driver->lastBind); + $this->assertEquals( + 'SELECT PATRON.PATRON_ID, PATRON.FIRST_NAME, PATRON.LAST_NAME, PATRON.LAST_NAME as LOGIN ' + . 'FROM .PATRON, .PATRON_BARCODE ' + . 'WHERE PATRON.PATRON_ID = PATRON_BARCODE.PATRON_ID AND ' + . 'lower(PATRON_BARCODE.PATRON_BARCODE) = :username AND PATRON_BARCODE.BARCODE_STATUS IN (1,4)', + $driver->lastSql + ); + } + + /** + * Get a Voyager driver customized to return a mock SQL response. + * + * @param MockObject&PDOStatement $mockResult Mock result to return from executeSQL + * + * @return Voyager + */ + protected function getDriverWithMockSqlResponse(MockObject&PDOStatement $mockResult): Voyager + { + return new class ($mockResult) extends Voyager { + /** + * Last SQL statement passed to executeSQL + * + * @var string + */ + public string $lastSql = ''; + + /** + * Last bind array passed to executeSQL + * + * @var array + */ + public array $lastBind = []; + + /** + * Constructor + * + * @param MockObject&PDOStatement $mockResult Mock result to return from executeSQL + */ + public function __construct(protected MockObject&PDOStatement $mockResult) + { + parent::__construct(new \VuFind\Date\Converter()); + } + + /** + * Execute an SQL query + * + * @param string|array $sql SQL statement (string or array that includes + * bind params) + * @param array $bind Bind parameters (if $sql is string) + * + * @return PDOStatement + */ + protected function executeSQL($sql, $bind = []) + { + $this->lastSql = $sql; + $this->lastBind = $bind; + return $this->mockResult; + } + }; + } } diff --git a/themes/bootstrap3/templates/Helpers/copy-to-clipboard-button.phtml b/themes/bootstrap3/templates/Helpers/copy-to-clipboard-button.phtml index 54c9e278873..1e024a98419 100644 --- a/themes/bootstrap3/templates/Helpers/copy-to-clipboard-button.phtml +++ b/themes/bootstrap3/templates/Helpers/copy-to-clipboard-button.phtml @@ -10,17 +10,17 @@ $script = <<escapeJs($id); $escSource = $this->escapeJs($source); $script = << $skipText = $this->transEsc('skip_confirm'); $script = << diff --git a/themes/bootstrap3/templates/upgrade/fixduplicatetags.phtml b/themes/bootstrap3/templates/upgrade/fixduplicatetags.phtml index d3340e2e604..a58434f27b8 100644 --- a/themes/bootstrap3/templates/upgrade/fixduplicatetags.phtml +++ b/themes/bootstrap3/templates/upgrade/fixduplicatetags.phtml @@ -27,7 +27,7 @@ otherwise, it is recommended that you fix these. Click Submit to proceed.

$confirmText = $this->transEsc('skip_confirm'); $script = << diff --git a/themes/bootstrap3/templates/upgrade/fixmetadata.phtml b/themes/bootstrap3/templates/upgrade/fixmetadata.phtml index 36d426af2e4..a6f44efb7bb 100644 --- a/themes/bootstrap3/templates/upgrade/fixmetadata.phtml +++ b/themes/bootstrap3/templates/upgrade/fixmetadata.phtml @@ -21,7 +21,7 @@ but it will improve the user experience by allowing proper sorting of favorites $confirmText = $this->transEsc('skip_confirm'); $script = << diff --git a/themes/bootstrap5/templates/Helpers/copy-to-clipboard-button.phtml b/themes/bootstrap5/templates/Helpers/copy-to-clipboard-button.phtml index 54c9e278873..1e024a98419 100644 --- a/themes/bootstrap5/templates/Helpers/copy-to-clipboard-button.phtml +++ b/themes/bootstrap5/templates/Helpers/copy-to-clipboard-button.phtml @@ -10,17 +10,17 @@ $script = <<escapeJs($id); $escSource = $this->escapeJs($source); $script = << $skipText = $this->transEsc('skip_confirm'); $script = << diff --git a/themes/bootstrap5/templates/upgrade/fixduplicatetags.phtml b/themes/bootstrap5/templates/upgrade/fixduplicatetags.phtml index d3340e2e604..a58434f27b8 100644 --- a/themes/bootstrap5/templates/upgrade/fixduplicatetags.phtml +++ b/themes/bootstrap5/templates/upgrade/fixduplicatetags.phtml @@ -27,7 +27,7 @@ otherwise, it is recommended that you fix these. Click Submit to proceed.

$confirmText = $this->transEsc('skip_confirm'); $script = << diff --git a/themes/bootstrap5/templates/upgrade/fixmetadata.phtml b/themes/bootstrap5/templates/upgrade/fixmetadata.phtml index 36d426af2e4..a6f44efb7bb 100644 --- a/themes/bootstrap5/templates/upgrade/fixmetadata.phtml +++ b/themes/bootstrap5/templates/upgrade/fixmetadata.phtml @@ -21,7 +21,7 @@ but it will improve the user experience by allowing proper sorting of favorites $confirmText = $this->transEsc('skip_confirm'); $script = <<