From 55f875d8b67f768f18d2b3d320621aa3e034eea9 Mon Sep 17 00:00:00 2001 From: blaipr Date: Thu, 13 Aug 2026 23:50:35 +0200 Subject: [PATCH] Report preferences that were not saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preferences page discarded the row count its own update returned, so a save whose WHERE matched nothing — the user's row deleted from another session — reported success. Worse than the other six services that did this: the caller then refreshed the session with the preferences it thought it had stored, so the page went on showing settings that exist nowhere until the next sign-in threw them away. This is the check the six others got; the preferences path was simply missed. Covered at both ends: the service raises when nothing was affected, and the page reports the failure and leaves the session alone. --- src/Application/User/Services/User.php | 12 +++++- .../UserSettingsGeneralTest.php | 43 +++++++++++++++++++ .../Application/User/Services/UserTest.php | 24 +++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/Application/User/Services/User.php b/src/Application/User/Services/User.php index c89e027a8..eca1521be 100644 --- a/src/Application/User/Services/User.php +++ b/src/Application/User/Services/User.php @@ -286,10 +286,20 @@ public function updatePass(int $userId, string $pass): void * @throws ConstraintException * @throws QueryException * @throws JsonException + * @throws ServiceException */ public function updatePreferencesById(int $userId, UserPreferences $userPreferences): int { - return $this->userRepository->updatePreferencesById($userId, $userPreferences); + $affected = $this->userRepository->updatePreferencesById($userId, $userPreferences); + + // An update whose WHERE matched nothing has saved nothing. Reporting success for it told + // the user their preferences had been stored — and the caller then refreshed the session + // with them, so the page even looked right until the next sign-in. + if ($affected === 0) { + throw ServiceException::error(__u('Error while updating the preferences')); + } + + return $affected; } /** diff --git a/tests/Integration/Infrastructure/Adapter/In/Web/Controllers/UserSettingsGeneral/UserSettingsGeneralTest.php b/tests/Integration/Infrastructure/Adapter/In/Web/Controllers/UserSettingsGeneral/UserSettingsGeneralTest.php index ad8279a6c..1c76b18d3 100644 --- a/tests/Integration/Infrastructure/Adapter/In/Web/Controllers/UserSettingsGeneral/UserSettingsGeneralTest.php +++ b/tests/Integration/Infrastructure/Adapter/In/Web/Controllers/UserSettingsGeneral/UserSettingsGeneralTest.php @@ -311,6 +311,49 @@ public function saveIsReportedAsFailedAndTheSessionIsLeftUntouchedWhenTheUpdateT ); } + /** + * And the same when the update raises nothing at all but simply matches no row — the user's + * own row deleted from another session, say. It saved nothing, so it is reported as a failure + * and the session is left alone, rather than the page showing settings that exist nowhere. + * + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + #[Test] + public function saveIsReportedAsFailedWhenTheUpdateMatchesNoRow() + { + $this->databaseQueryResolver = function (QueryData $queryData): QueryResult { + $statement = $queryData->getQuery()->getStatement(); + + if (str_starts_with($statement, 'UPDATE') && str_contains($statement, 'preferences')) { + return new QueryResult([], 0, 0); + } + + return new QueryResult([], 1, 100); + }; + + $container = $this->buildContainer( + IntegrationTestCase::buildRequest( + 'post', + 'index.php', + ['r' => 'userSettingsGeneral/save'], + self::fullPreferencesFields() + ) + ); + + IntegrationTestCase::runApp($container); + + $this->expectOutputString( + '{"status":"ERROR","description":"Error while updating the preferences","data":null}' + ); + + self::assertNull( + $this->refreshedSessionUserData, + 'the session must not be refreshed with preferences that were never actually written' + ); + } + /** * Registers a resolver that captures the bind values of the UPDATE issued against the * preferences column into {@see $capturedUpdateBindValues}. Every other query on the diff --git a/tests/Unit/Application/User/Services/UserTest.php b/tests/Unit/Application/User/Services/UserTest.php index e554c81fc..c5a0f9d7b 100644 --- a/tests/Unit/Application/User/Services/UserTest.php +++ b/tests/Unit/Application/User/Services/UserTest.php @@ -142,6 +142,30 @@ public function testUpdatePreferencesById() $this->assertEquals(10, $out); } + /** + * A user whose row is no longer there has not had their preferences saved, and saying so beats + * reporting success — the caller refreshes the session with what it thinks was stored, so + * without this the page looks right until the next sign-in throws it away. + * + * @throws Exception + * @throws ServiceException + */ + public function testUpdatePreferencesByIdOfAUserThatIsNotThereIsReported() + { + $userPreferences = UserDataGenerator::factory()->buildUserPreferencesData(); + + $this->userRepository + ->expects($this->once()) + ->method('updatePreferencesById') + ->with(100, $userPreferences) + ->willReturn(0); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Error while updating the preferences'); + + $this->user->updatePreferencesById(100, $userPreferences); + } + public function testGetAll() { $user = UserDataGenerator::factory()->buildUserData();