Repository: glpi-project/glpi
Pull request #19638: Fix flaky display preferences e2e test 
Author: login: AdrienClairembault
Created: 2025-05-05T12:55:22Z | Merged: 2025-05-07T15:32:43Z

## Checklist before requesting a review

- [X] I have performed a self-review of my code.
- [ ] I have added tests that prove my fix is effective or that my feature works.

## Description

Try to make the `display_preferences.cy.js` display pref test less flaky by making sure it can be retried.

Before this change, retries were worthless on this test because it need a specific search option to not be set (if the test fail on the first run after setting it, all retries will thus fail).

These change reset the target search option using the CI.
This required adding some search options for the display preference items.





Diff:
diff --git a/src/DisplayPreference.php b/src/DisplayPreference.php
index c01395437dd..b80ca6c1a12 100644
--- a/src/DisplayPreference.php
+++ b/src/DisplayPreference.php
@@ -54,6 +54,78 @@ class DisplayPreference extends CommonDBTM
     public const PERSONAL = 1024;
     public const GENERAL  = 2048;
 
+    public static function canCrud(): bool
+    {
+        return Session::haveRightsOr(
+            static::$rightname,
+            [DisplayPreference::PERSONAL | DisplayPreference::GENERAL],
+        );
+    }
+
+    public function canCrudItem(): bool
+    {
+        if (Session::haveRight(static::$rightname, DisplayPreference::GENERAL)) {
+            return true;
+        }
+
+        if (
+            Session::haveRight(static::$rightname, DisplayPreference::PERSONAL)
+            && $this->fields['users_id'] == Session::getLoginUserID()
+        ) {
+            return true;
+        }
+
+        return false;
+    }
+
+    #[Override]
+    public static function canCreate(): bool
+    {
+        return static::canCrud();
+    }
+
+    #[Override]
+    public static function canView(): bool
+    {
+        return static::canCrud();
+    }
+
+    #[Override]
+    public static function canUpdate(): bool
+    {
+        return static::canCrud();
+    }
+
+    #[Override]
+    public static function canPurge(): bool
+    {
+        return static::canCrud();
+    }
+
+    #[Override]
+    public function canCreateItem(): bool
+    {
+        return $this->canCrudItem();
+    }
+
+    #[Override]
+    public function canViewItem(): bool
+    {
+        return $this->canCrudItem();
+    }
+
+    #[Override]
+    public function canUpdateItem(): bool
+    {
+        return $this->canCrudItem();
+    }
+
+    #[Override]
+    public function canPurgeItem(): bool
+    {
+        return $this->canCrudItem();
+    }
+
     public static function getTypeName($nb = 0)
     {
         return __('Search result display');
@@ -763,4 +835,37 @@ public static function resetToDefaultOptions(string $itemtype): bool
         }
         return true;
     }
+
+    #[Override]
+    public function rawSearchOptions()
+    {
+        $search_options = parent::rawSearchOptions();
+
+        $search_options[] = [
+            'id'            => '2',
+            'table'         => $this->getTable(),
+            'field'         => 'id',
+            'name'          => __('ID'),
+            'massiveaction' => false,
+            'datatype'      => 'number',
+        ];
+        $search_options[] = [
+            'id'            => '3',
+            'table'         => $this->getTable(),
+            'field'         => 'itemtype',
+            'name'          => __('Itemtype'),
+            'massiveaction' => false,
+            'datatype'      => 'itemtypename',
+        ];
+        $search_options[] = [
+            'id'            => '4',
+            'table'         => $this->getTable(),
+            'field'         => 'num',
+            'name'          => __('Search option ID'),
+            'massiveaction' => false,
+            'datatype'      => 'number',
+        ];
+
+        return $search_options;
+    }
 }
diff --git a/tests/cypress/e2e/form/form_translation.cy.js b/tests/cypress/e2e/form/form_translation.cy.js
index 3566c3c16cc..907ac8a76b9 100644
--- a/tests/cypress/e2e/form/form_translation.cy.js
+++ b/tests/cypress/e2e/form/form_translation.cy.js
@@ -71,8 +71,7 @@ function checkTranslations(title, description) {
 }
 
 function changeUserLanguage(language) {
-    cy.updateWithAPI('User', {
-        'id': 7, // E2E user ID
+    cy.updateTestUserSettings({
         'language': language
     });
 
@@ -102,8 +101,7 @@ describe('Edit form translations', () => {
 
     afterEach(() => {
         // Make sure the user language is reset to default
-        cy.updateWithAPI('User', {
-            'id': 7, // E2E user ID
+        cy.updateTestUserSettings({
             'language': null
         });
     });
@@ -281,8 +279,7 @@ describe('Edit form translations', () => {
 
         // Modify the default values of the form
         cy.get('@form_id').then((form_id) => {
-            cy.updateWithAPI('Glpi\\Form\\Form', {
-                'id': form_id,
+            cy.updateWithAPI('Glpi\\Form\\Form', form_id, {
                 'name': 'Tests form translations updated',
             });
         });
diff --git a/tests/cypress/e2e/search/display_preferences.cy.js b/tests/cypress/e2e/search/display_preferences.cy.js
index 6ac8bd3117c..6c2d96eb397 100644
--- a/tests/cypress/e2e/search/display_preferences.cy.js
+++ b/tests/cypress/e2e/search/display_preferences.cy.js
@@ -30,7 +30,7 @@
  * ---------------------------------------------------------------------
  */
 
-describe('Display preferences', () => {
+describe('Display preferences', { retries: {runMode: 0, openMode: 0} }, () => {
     before(() => {
         // Create at least one ticket as we will be displaying the ticket list
         // to validate that the right columns are displayed
@@ -38,6 +38,19 @@ describe('Display preferences', () => {
             'name': 'Open ticket',
             'content': 'Open ticket',
         });
+
+        // Make sure "pending reason" search option doens't exist as it will be
+        // created by these tests.
+        cy.searchWithAPI("DisplayPreference", [{
+            link: "AND",
+            field: 4, // Search option ID
+            searchtype: "equals",
+            value: 400, // Pending reason search option ID
+        }]).then((data) => {
+            for (const row of data) {
+                cy.deleteWithAPI("DisplayPreference", row[2]);
+            }
+        });
     });
 
     it('can add a column to the global view', () => {
@@ -54,12 +67,12 @@ describe('Display preferences', () => {
         cy.reload();
 
         // Make sure the column was added to the ticket list (still as admin)
-        cy.findByRole('columnheader', {'name': 'Pending reason'}).should('be.visible');
+        cy.findByRole('columnheader', {name: "Pending reason"}).should('exist');
 
         // Switch to helpdesk and make sure the column was not added
         cy.changeProfile('Self-Service', true);
         cy.visit('/front/ticket.php');
-        cy.findByRole('columnheader', {'name': 'Pending reason'}).should('not.exist');
+        cy.findByRole('columnheader', {name: "Pending reason"}).should('not.exist');
 
         // Go back to super admin
         cy.changeProfile('Super-Admin', true);
@@ -93,12 +106,12 @@ describe('Display preferences', () => {
         cy.reload();
 
         // Make sure the column was not added to the central ticket list
-        cy.findByRole('columnheader', {'name': 'Pending reason'}).should('not.exist');
+        cy.findByRole('columnheader', {name: "Pending reason"}).should('not.exist');
 
         // Switch to helpdesk and make sure the column was added
         cy.changeProfile('Self-Service', true);
         cy.visit('/front/ticket.php');
-        cy.findByRole('columnheader', {'name': 'Pending reason'}).should('be.visible');
+        cy.findByRole('columnheader', {name: "Pending reason"}).should('exist');
 
         // Go back to super admin
         cy.changeProfile('Super-Admin', true);
diff --git a/tests/cypress/e2e/self-service/home_config.cy.js b/tests/cypress/e2e/self-service/home_config.cy.js
index 7a980583f78..25178c80a79 100644
--- a/tests/cypress/e2e/self-service/home_config.cy.js
+++ b/tests/cypress/e2e/self-service/home_config.cy.js
@@ -110,7 +110,7 @@ for (const test of tests) {
 
             // Need the JS controller to be ready
             // eslint-disable-next-line cypress/no-unnecessary-waiting
-            cy.wait(1000);
+            cy.wait(1200);
 
             // html5sortable add the wrong "option" role, we must remove it
             cy.window().then((win) => {
diff --git a/tests/cypress/support/commands.d.ts b/tests/cypress/support/commands.d.ts
index daa276c8743..ae4246301be 100644
--- a/tests/cypress/support/commands.d.ts
+++ b/tests/cypress/support/commands.d.ts
@@ -41,5 +41,9 @@ declare namespace Cypress {
         validateBreadcrumbs(breadcrumbs: array): Chainable<any>
         validateMenuIsActive(name: string): Chainable<any>
         updateTestUserSettings(object: settings): Chainable<any>
+        createWithAPI(itemtype: string, values, array): Chainable<any>
+        updateWithAPI(itemtype: string, values, array): Chainable<any>
+        deleteWithAPI(itemtype): Chainable<any>
+        searchWithAPI(itemtype: string, values, array): Chainable<any>
     }
 }
diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js
index 9eaaffc85c8..c1b20a976a7 100644
--- a/tests/cypress/support/commands.js
+++ b/tests/cypress/support/commands.js
@@ -348,17 +348,17 @@ Cypress.Commands.add("getWithAPI", (itemtype, id) => {
  * @memberof Cypress.Chainable.prototype
  * @method createWithAPI
  * @description Create an item using the legacy API
- * @param {string} url API endpoint
+ * @param {string} itemtype API endpoint
  * @param {object} values Values to create the item with
  */
-Cypress.Commands.add("createWithAPI", (url, values) => {
-    return cy.initApi().doApiRequest("POST", url, values).then(response => {
+Cypress.Commands.add("createWithAPI", (itemtype, values) => {
+    return cy.initApi().doApiRequest("POST", itemtype, values).then(response => {
         if (response.status !== 201) {
             throw new Error('Failed to create item');
         }
 
         // Session can't be re-used as active entities will be invalid...
-        if (url == "Entity") {
+        if (itemtype == "Entity") {
             api_token = null;
         }
 
@@ -370,11 +370,38 @@ Cypress.Commands.add("createWithAPI", (url, values) => {
  * @memberof Cypress.Chainable.prototype
  * @method updateWithAPI
  * @description Update an item using the legacy API
- * @param {string} url API endpoint
+ * @param {string} itemtype API endpoint
  * @param {object} values Values to update the item with
  */
-Cypress.Commands.add("updateWithAPI", (url, values) => {
-    cy.initApi().doApiRequest("PUT", url, values);
+Cypress.Commands.add("updateWithAPI", (itemtype, id, values) => {
+    cy.initApi().doApiRequest("PUT", `${itemtype}/${id}`, values);
+});
+
+Cypress.Commands.add("deleteWithAPI", (itemtype, id) => {
+    cy.initApi().doApiRequest("DELETE", `${itemtype}/${id}`);
+});
+
+Cypress.Commands.add("searchWithAPI", (itemtype, values) => {
+    let url = `search/${itemtype}`;
+
+    let i = 0;
+    for (const criteria of values) {
+        url += i == 0 ? "?" : "&";
+        url += `criteria[${i}][link]=${criteria.link}`;
+        url += `&criteria[${i}][field]=${criteria.field}`;
+        url += `&criteria[${i}][searchtype]=${criteria.searchtype}`;
+        url += `&criteria[${i}][value]=${criteria.value}`;
+        i++;
+    }
+
+    return cy.initApi().doApiRequest("GET", url).then((response) => {
+        if (response.body.count == 0) {
+            // The "data" key does not exist in the API response if there are 0 results.
+            return [];
+        }
+
+        return response.body.data;
+    });
 });
 
 /**
@@ -551,5 +578,5 @@ Cypress.Commands.add('closeAccordionItem', (container_label, item_label) => {
 });
 
 Cypress.Commands.add('updateTestUserSettings', (settings) => {
-    return cy.updateWithAPI('User/7', settings);
+    return cy.updateWithAPI('User', 7, settings);
 });


Reviews:
user: login: trasher
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:56:01Z
user: login: cedric-anne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T12:48:21Z
user: login: cedric-anne
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T12:52:25Z
user: login: AdrienClairembault
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T12:55:17Z
user: login: cedric-anne
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-06T14:19:33Z
user: login: cedric-anne
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T09:02:54Z
user: login: cedric-anne
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T15:32:14Z

Review comments:
diff_hunk: @@ -6665,7 +6665,7 @@ public function getEmptyData(): array
             ], [
                 'profiles_id' => self::PROFILE_SUPER_ADMIN,
                 'name' => 'search_config',
-                'rights' => DisplayPreference::PERSONAL | DisplayPreference::GENERAL,
+                'rights' => READ | UPDATE | CREATE | PURGE | DisplayPreference::PERSONAL | DisplayPreference::GENERAL,
path: install/empty_data.php
user: login: cedric-anne
body: Should we add `READ | UPDATE | CREATE | PURGE` rights to the `DisplayPreference::getRights()` method ?

Right now, if you change the profile configuration for this profile, as these rights are not present in the rights matrix, they will be disabled.
created_at: 2025-05-06T12:46:16Z
author_association: MEMBER
diff_hunk: @@ -763,4 +763,37 @@ public static function resetToDefaultOptions(string $itemtype): bool
         }
         return true;
     }
+
+    #[Override]
+    public function rawSearchOptions()
+    {
+        $search_options = parent::rawSearchOptions();
+
+        $search_options[] = [
+            'id'            => '2',
+            'table'         => $this->getTable(),
+            'field'         => 'id',
+            'name'          => __('ID'),
+            'massiveaction' => false,
+            'datatype'      => 'number',
+        ];
+        $search_options[] = [
+            'id'            => '3',
+            'table'         => $this->getTable(),
+            'field'         => 'itemtype',
+            'name'          => __('Itemtype'),
+            'massiveaction' => false,
+            'datatype'      => 'string',
path: src/DisplayPreference.php
user: login: cedric-anne
body: ```suggestion
            'datatype'      => 'itemtypename',
```
created_at: 2025-05-06T12:47:41Z
author_association: MEMBER
diff_hunk: @@ -6665,7 +6665,7 @@ public function getEmptyData(): array
             ], [
                 'profiles_id' => self::PROFILE_SUPER_ADMIN,
                 'name' => 'search_config',
-                'rights' => DisplayPreference::PERSONAL | DisplayPreference::GENERAL,
+                'rights' => READ | UPDATE | CREATE | PURGE | DisplayPreference::PERSONAL | DisplayPreference::GENERAL,
path: install/empty_data.php
user: login: cedric-anne
body: Or you can change the `can*` methods to check for the `PERSONAL`/`GENERAL` value.
created_at: 2025-05-06T12:52:25Z
author_association: MEMBER
diff_hunk: @@ -6665,7 +6665,7 @@ public function getEmptyData(): array
             ], [
                 'profiles_id' => self::PROFILE_SUPER_ADMIN,
                 'name' => 'search_config',
-                'rights' => DisplayPreference::PERSONAL | DisplayPreference::GENERAL,
+                'rights' => READ | UPDATE | CREATE | PURGE | DisplayPreference::PERSONAL | DisplayPreference::GENERAL,
path: install/empty_data.php
user: login: AdrienClairembault
body: I think we should modify the can* methods on display pref and make them use the correct rights that already exist.
created_at: 2025-05-06T12:55:17Z
author_association: MEMBER

PR comments:
user: login: cedric-anne
created_at: 2025-05-06T16:07:47Z
body: It seems that the display preferences test failed.
author_association: MEMBER
user: login: AdrienClairembault
created_at: 2025-05-07T08:58:56Z
body: Seems like the retry things doesn't work on the CI, it isn't able to remove the configured search option using the API. Weird.
We can still keep the change as it works locally, I might try to fix it later.

Regarding the issue itself of why this test is suddenly way more flaky, I have found the real error:

![image](https://github.com/user-attachments/assets/6f847cb0-53f3-4156-a56c-536511167337)

It looks like it is just the search result having too many colum which mean the one we are testing is not showed without scrolling.
Unsure which recent change caused this but I've added the `force` option which should bypass this visibility issue.

author_association: MEMBER
user: login: trasher
created_at: 2025-05-07T10:06:37Z
body: display_pref test has failed once again :/
author_association: CONTRIBUTOR
user: login: AdrienClairembault
created_at: 2025-05-07T15:09:23Z
body: Should be finally ok 🥳
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2026-01-31T00:36:43Z
body: This PR has been is closed and has been automatically locked. Please open a new issue with all requested information for related bugs.
author_association: CONTRIBUTOR