1+ package org .labkey .test .tests .list ;
2+
3+ import org .apache .hc .core5 .http .HttpStatus ;
4+ import org .junit .Assert ;
5+ import org .junit .BeforeClass ;
6+ import org .junit .Test ;
7+ import org .junit .experimental .categories .Category ;
8+ import org .labkey .api .collections .CaseInsensitiveHashMap ;
9+ import org .labkey .remoteapi .CommandException ;
10+ import org .labkey .test .BaseWebDriverTest ;
11+ import org .labkey .test .Locator ;
12+ import org .labkey .test .TestFileUtils ;
13+ import org .labkey .test .WebTestHelper ;
14+ import org .labkey .test .categories .Daily ;
15+ import org .labkey .test .categories .Data ;
16+ import org .labkey .test .categories .Hosting ;
17+ import org .labkey .test .components .list .ManageListsGrid ;
18+ import org .labkey .test .pages .list .BeginPage ;
19+ import org .labkey .test .util .DataRegionTable ;
20+ import org .labkey .test .params .FieldDefinition ;
21+ import org .labkey .test .params .list .IntListDefinition ;
22+ import org .labkey .test .params .list .VarListDefinition ;
23+ import org .labkey .test .util .DomainUtils ;
24+ import org .labkey .test .util .TestDataGenerator ;
25+ import org .labkey .test .util .TestUser ;
26+ import org .openqa .selenium .By ;
27+ import org .openqa .selenium .support .ui .ExpectedConditions ;
28+
29+ import java .io .File ;
30+ import java .io .IOException ;
31+ import java .util .List ;
32+
33+ import static org .junit .Assert .assertEquals ;
34+ import static org .junit .Assert .assertTrue ;
35+ import static org .labkey .test .util .PermissionsHelper .EDITOR_ROLE ;
36+
37+ @ Category ({Daily .class , Data .class , Hosting .class })
38+ @ BaseWebDriverTest .ClassTimeout (minutes = 5 )
39+ public class ListDeleteTest extends BaseWebDriverTest
40+ {
41+ private static final String PROJECT_NAME = "ListDeleteTest" ;
42+ private static final String PROJECT_PATH = "/" + PROJECT_NAME ;
43+ private static final String SUBFOLDER_A_NAME = "SubfolderA" ;
44+ private static final String SUBFOLDER_A_PATH = PROJECT_PATH + "/" + SUBFOLDER_A_NAME ;
45+ private static final String SUBFOLDER_B_NAME = "SubfolderB" ;
46+ private static final String SUBFOLDER_B_PATH = PROJECT_PATH + "/" + SUBFOLDER_B_NAME ;
47+
48+ private static final TestUser LIST_DESIGNER_USER = new TestUser ("listdesigner@listdelete.test" );
49+
50+ private static final String attachmentFieldName = TestDataGenerator .randomFieldName ("Attachment" , null , DomainUtils .DomainKind .IntList );
51+ private static final String booleanFieldName = TestDataGenerator .randomFieldName ("Boolean" , null , DomainUtils .DomainKind .IntList );
52+ private static final String integerFieldName = TestDataGenerator .randomFieldName ("Integer" , null , DomainUtils .DomainKind .IntList );
53+ private static final String stringFieldName = TestDataGenerator .randomFieldName ("String" , null , DomainUtils .DomainKind .IntList );
54+
55+ private static final String autoIncrementKeyFieldName1 = TestDataGenerator .randomFieldName ("Key" , null , DomainUtils .DomainKind .IntList );
56+
57+ protected static final File IMG_FILE = TestFileUtils .getSampleData ("InlineImages/help.jpg" ); // use in Project
58+ protected static final File PDF_FILE = TestFileUtils .getSampleData ("InlineImages/agraph.pdf" ); // use in Subfolder A
59+ protected static final File TXT_FILE = TestFileUtils .getSampleData ("InlineImages/test.txt" ); // use in Subfolder B
60+
61+ private static IntListDefinition LIST_1 ; // int list with attachment column
62+ private static VarListDefinition LIST_2 ; // var list with attachment column, keyed by string
63+
64+ @ BeforeClass
65+ public static void setupProject () throws Exception
66+ {
67+ ListDeleteTest init = getCurrentTest ();
68+ init .doSetup ();
69+ }
70+
71+ private void doSetup () throws Exception
72+ {
73+ // Create project and subfolders
74+ _containerHelper .createProject (getProjectName (), null );
75+ _containerHelper .createSubfolder (getProjectName (), SUBFOLDER_A_NAME );
76+ _containerHelper .createSubfolder (getProjectName (), SUBFOLDER_B_NAME );
77+
78+ // Create user with Assay Designer + Editor permissions
79+ LIST_DESIGNER_USER .create (this )
80+ .setInitialPassword ()
81+ .addPermission (EDITOR_ROLE , PROJECT_PATH )
82+ .addPermission ("Assay Designer" , PROJECT_PATH )
83+ .addPermission (EDITOR_ROLE , SUBFOLDER_A_PATH )
84+ .addPermission ("Assay Designer" , SUBFOLDER_A_PATH )
85+ .addPermission (EDITOR_ROLE , SUBFOLDER_B_PATH );
86+
87+ var conn = createDefaultConnection ();
88+
89+ // Create list 1 with attachment column
90+ var list1Name = DomainUtils .DomainKind .IntList .randomName ("DEL1" );
91+ LIST_1 = (IntListDefinition ) new IntListDefinition (list1Name , autoIncrementKeyFieldName1 )
92+ .setFields (List .of (
93+ new FieldDefinition (attachmentFieldName , FieldDefinition .ColumnType .Attachment ),
94+ new FieldDefinition (booleanFieldName , FieldDefinition .ColumnType .Boolean ),
95+ new FieldDefinition (integerFieldName , FieldDefinition .ColumnType .Integer ),
96+ new FieldDefinition (stringFieldName , FieldDefinition .ColumnType .String )
97+ ));
98+ LIST_1 .getCreateCommand ().execute (conn , PROJECT_PATH );
99+
100+ // Create list 2 — var list with attachment column, keyed by string
101+ var stringKeyField = new FieldDefinition (stringFieldName );
102+ var list2Name = DomainUtils .DomainKind .VarList .randomName ("DEL2" );
103+ LIST_2 = (VarListDefinition ) new VarListDefinition (list2Name )
104+ .setKeyName (stringKeyField .getName ())
105+ .setFields (List .of (
106+ stringKeyField ,
107+ new FieldDefinition (attachmentFieldName , FieldDefinition .ColumnType .Attachment ),
108+ new FieldDefinition (booleanFieldName , FieldDefinition .ColumnType .Boolean ),
109+ new FieldDefinition (integerFieldName , FieldDefinition .ColumnType .Integer )
110+ ));
111+ LIST_2 .getCreateCommand ().execute (conn , PROJECT_PATH );
112+
113+ // Populate data in project folder for both lists
114+ populateList1 (PROJECT_PATH , IMG_FILE );
115+ populateList2 (PROJECT_PATH , IMG_FILE );
116+
117+ // Populate data in subfolder A for both lists
118+ populateList1 (SUBFOLDER_A_PATH , PDF_FILE );
119+ populateList2 (SUBFOLDER_A_PATH , PDF_FILE );
120+
121+ // Populate data in subfolder B for both lists
122+ populateList1 (SUBFOLDER_B_PATH , TXT_FILE );
123+ populateList2 (SUBFOLDER_B_PATH , TXT_FILE );
124+ }
125+
126+ private void populateList1 (String containerPath , File attachment ) throws IOException , CommandException
127+ {
128+ var dataGenerator = LIST_1 .getTestDataGenerator (containerPath )
129+ .addDataSupplier (attachmentFieldName , () -> attachment );
130+
131+ // Insert rows with attachment values via UI (only way to provide attachment values)
132+ var attachmentRows = dataGenerator .withGeneratedRows (1 )
133+ .getRows ();
134+
135+ _listHelper .beginAtList (containerPath , LIST_1 .getName ());
136+ var newRow = new CaseInsensitiveHashMap <>();
137+ newRow .putAll (attachmentRows .getFirst ());
138+ _listHelper .insertNewRow (newRow , false );
139+ }
140+
141+ private void populateList2 (String containerPath , File attachment ) throws IOException , CommandException
142+ {
143+ var dataGenerator = LIST_2 .getTestDataGenerator (containerPath )
144+ .addDataSupplier (stringFieldName , () -> "String" + containerPath )
145+ .addDataSupplier (attachmentFieldName , () -> attachment );
146+
147+ // Insert rows without attachments via API
148+ var attachmentRows = dataGenerator .withGeneratedRows (1 )
149+ .getRows ();
150+
151+ _listHelper .beginAtList (containerPath , LIST_2 .getName ());
152+ var newRow = new CaseInsensitiveHashMap <>();
153+ newRow .putAll (attachmentRows .getFirst ());
154+ _listHelper .insertNewRow (newRow , false );
155+ }
156+
157+ private void verifyConfirmationPage (String containerPath , List <String > listNames )
158+ {
159+ // Navigate to manage lists page, clear all row selections, verify delete button is disabled
160+ var listsPage = BeginPage .beginAt (this , containerPath );
161+ var grid = listsPage .getGrid ();
162+ grid .uncheckAllOnPage ();
163+ var deleteButton = grid .getHeaderButton ("Delete" );
164+ Assert .assertTrue ("Delete button should be disabled when no rows selected" ,
165+ deleteButton .getAttribute ("class" ).contains ("disabled" ));
166+
167+ // Select lists, verify delete menu is enabled and has 2 options
168+ selectLists (grid , listNames );
169+ var menuOptions = grid .getHeaderMenuOptions ("Delete" );
170+ Assert .assertEquals ("Expected 2 delete menu options" ,
171+ List .of ("Delete List" , "Delete All Data from List" ), menuOptions );
172+
173+ // Click DELETE -> "Delete List", verify landing on confirmation page with expected text, cancel
174+ listsPage = BeginPage .beginAt (this , containerPath );
175+ grid = listsPage .getGrid ();
176+ selectLists (grid , listNames );
177+ grid .clickHeaderMenu ("Delete" , true , "Delete List" );
178+ assertTextPresent ("Are you sure you want to delete the following Lists?" );
179+ for (String listName : listNames )
180+ assertElementPresent (Locator .linkWithText (listName ));
181+ clickButton ("Cancel" );
182+
183+ // Click DELETE -> "Delete All Data from List", verify landing on confirmation page with expected text, cancel
184+ listsPage = BeginPage .beginAt (this , containerPath );
185+ grid = listsPage .getGrid ();
186+ selectLists (grid , listNames );
187+ grid .clickHeaderMenu ("Delete" , true , "Delete All Data from List" );
188+ assertTextPresent ("Are you sure you want to delete all data" );
189+ assertTextPresent ("This action cannot be undone and will result in an empty list." );
190+ for (String listName : listNames )
191+ {
192+ assertElementPresent (Locator .linkWithText (listName ));
193+ }
194+ assertTextPresent ("1 row" );
195+ clickButton ("Cancel" );
196+ }
197+
198+ private void selectLists (ManageListsGrid grid , List <String > listNames )
199+ {
200+ for (String listName : listNames )
201+ grid .checkCheckbox (grid .getRowIndex ("Name" , listName ));
202+ }
203+
204+ private void verifyListRowCount (String containerPath , String listName , int expectedCount )
205+ {
206+ _listHelper .beginAtList (containerPath , listName );
207+ var table = new DataRegionTable ("query" , getDriver ());
208+ Assert .assertEquals ("Expected " + expectedCount + " rows in " + listName + " at " + containerPath ,
209+ expectedCount , table .getDataRowCount ());
210+ }
211+
212+ private void verifyListDataWithAttachment (String containerPath , String listName , int expectedCount , String attachmentFileName )
213+ {
214+ _listHelper .beginAtList (containerPath , listName );
215+ var table = new DataRegionTable ("query" , getDriver ());
216+ Assert .assertEquals ("Expected " + expectedCount + " rows in " + listName + " at " + containerPath ,
217+ expectedCount , table .getDataRowCount ());
218+
219+ if (attachmentFileName .contains (IMG_FILE .getName ()))
220+ {
221+ log ("Hover over the thumbnail for the image and make sure the pop-up is as expected." );
222+ // Mouse over the logo, migh help with the following mouse over the image.
223+ mouseOver (Locator .tagWithAttributeContaining ("img" , "src" , IMG_FILE .getName ()));
224+ sleep (500 );
225+ mouseOver (Locator .xpath ("//img[contains(@title, '" + IMG_FILE .getName () + "')]" ));
226+ longWait ().until (ExpectedConditions .visibilityOfElementLocated (By .cssSelector ("#helpDiv" )));
227+ String src = Locator .xpath ("//div[@id='helpDiv']//img[contains(@src, 'download')]" ).findElement (getDriver ()).getAttribute ("src" );
228+ assertTrue ("Wrong image in popup: " + src , src .contains (IMG_FILE .getName ()));
229+ assertEquals ("Bad response from image pop-up" , HttpStatus .SC_OK , WebTestHelper .getHttpResponse (src ).getResponseCode ());
230+
231+ }
232+ else
233+ {
234+ File download = doAndWaitForDownload (() -> click (Locator .linkWithText (attachmentFileName )));
235+ Assert .assertTrue ("Downloaded attachment should exist: " + attachmentFileName , download .exists ());
236+ }
237+
238+ }
239+
240+ /**
241+ * Verifies list deletion and data truncation across folders and permission levels.
242+ *
243+ * <ol>
244+ * <li>Verify "Delete List" and "Delete All Data from List" confirmation pages render correctly
245+ * for single and multi-list selections across project and subfolders.</li>
246+ * <li>Impersonate a non-admin designer user (Editor + Assay Designer) and verify they see a
247+ * simple "Delete" button (no "Delete All Data" menu option) and can reach the delete
248+ * confirmation page. Cancel without deleting.</li>
249+ * <li>As admin, truncate all list data from Subfolder A. Verify both lists are empty in
250+ * Subfolder A while data and attachments remain intact in the project and Subfolder B.</li>
251+ * <li>As admin, truncate only LIST_2 data from the project. Verify LIST_2 is empty in the
252+ * project while LIST_1 data in the project and all data in Subfolder B are unaffected.</li>
253+ * <li>Impersonate the designer user again. Verify no Delete button appears in Subfolder B
254+ * (where the user lacks Assay Designer permission). Delete LIST_1 from Subfolder A and
255+ * LIST_2 from the project, verifying the list definitions are removed.</li>
256+ * </ol>
257+ */
258+ @ Test
259+ public void testDeleteListData () throws IOException , CommandException
260+ {
261+ verifyConfirmationPage (getProjectName (), List .of (LIST_1 .getName ()));
262+ verifyConfirmationPage (getProjectName (), List .of (LIST_1 .getName (), LIST_2 .getName ()));
263+ verifyConfirmationPage (SUBFOLDER_A_PATH , List .of (LIST_1 .getName ()));
264+ verifyConfirmationPage (SUBFOLDER_B_PATH , List .of (LIST_1 .getName (), LIST_2 .getName ()));
265+
266+ LIST_DESIGNER_USER .impersonate ();
267+
268+ // verify DESIGNER don't see the menu option to "Delete All Data from List", only "Delete" button
269+ var listsPage = BeginPage .beginAt (this , getProjectName ());
270+ var grid = listsPage .getGrid ();
271+ grid .uncheckAllOnPage ();
272+ var deleteButton = grid .getHeaderButton ("Delete" );
273+ Assert .assertTrue ("Delete button should be disabled when no rows selected" ,
274+ deleteButton .getAttribute ("class" ).contains ("disabled" ));
275+
276+ // Select lists, verify delete menu is enabled and has 2 options
277+ selectLists (grid , List .of (LIST_1 .getName (), LIST_2 .getName ()));
278+ // verify DELETE button is enabled, verify click DELETE land on confirmation page, click cancel
279+ Assert .assertFalse ("Delete button should be enabled when rows are selected" ,
280+ deleteButton .getAttribute ("class" ).contains ("disabled" ));
281+ grid .clickHeaderButton ("Delete" );
282+ assertTextPresent ("Are you sure you want to delete the following Lists?" );
283+ for (String listName : List .of (LIST_1 .getName (), LIST_2 .getName ()))
284+ assertElementPresent (Locator .linkWithText (listName ));
285+ clickButton ("Cancel" );
286+ stopImpersonating ();
287+
288+ // Verify deleting data from Subfolder A doesn't impact lists or data in project folder or Subfolder B
289+ listsPage = BeginPage .beginAt (this , SUBFOLDER_A_PATH );
290+ grid = listsPage .getGrid ();
291+ selectLists (grid , List .of (LIST_1 .getName (), LIST_2 .getName ()));
292+ grid .clickHeaderMenu ("Delete" , true , "Delete All Data from List" );
293+ assertTextPresent ("Are you sure you want to delete all data" );
294+ assertTextPresent ("This action cannot be undone and will result in an empty list." );
295+ for (String listName : List .of (LIST_1 .getName (), LIST_2 .getName ()))
296+ {
297+ assertElementPresent (Locator .linkWithText (listName ));
298+ assertTextPresent ("1 row" );
299+ }
300+ clickButton ("Confirm Delete All Data" );
301+
302+ // Verify data deleted in Subfolder A — both lists should be empty
303+ verifyListRowCount (SUBFOLDER_A_PATH , LIST_1 .getName (), 0 );
304+ verifyListRowCount (SUBFOLDER_A_PATH , LIST_2 .getName (), 0 );
305+
306+ // Verify data still exists in project folder
307+ // Go to LIST_1, verify grid is not empty, verify attachment can still be downloaded successfully
308+ verifyListDataWithAttachment (PROJECT_PATH , LIST_1 .getName (), 1 , IMG_FILE .getName ());
309+
310+ // Go to Subfolder B, go to LIST_2, verify data present, verify attachment can still be downloaded successfully
311+ verifyListDataWithAttachment (SUBFOLDER_B_PATH , LIST_2 .getName (), 1 , TXT_FILE .getName ());
312+
313+ // Now delete just LIST_2 data from project folder
314+ listsPage = BeginPage .beginAt (this , PROJECT_PATH );
315+ grid = listsPage .getGrid ();
316+ selectLists (grid , List .of (LIST_2 .getName ()));
317+ grid .clickHeaderMenu ("Delete" , true , "Delete All Data from List" );
318+
319+ // Verify confirmation page
320+ assertTextPresent ("Are you sure you want to delete all data" );
321+ assertElementPresent (Locator .linkWithText (LIST_2 .getName ()));
322+ assertElementNotPresent (Locator .linkWithText (LIST_1 .getName ()));
323+ assertTextPresent ("1 row" );
324+ clickButton ("Confirm Delete All Data" );
325+
326+ // Verify data deleted from LIST_2 in project
327+ verifyListRowCount (PROJECT_PATH , LIST_2 .getName (), 0 );
328+
329+ // Verify data still present in LIST_2 in Subfolder B and in LIST_1 in both folders
330+ verifyListRowCount (SUBFOLDER_B_PATH , LIST_2 .getName (), 1 );
331+ verifyListRowCount (PROJECT_PATH , LIST_1 .getName (), 1 );
332+ verifyListRowCount (SUBFOLDER_B_PATH , LIST_1 .getName (), 1 );
333+
334+ LIST_DESIGNER_USER .impersonate ();
335+
336+ // From Subfolder B, verify LIST_DESIGNER_USER cannot delete LIST_1 or LIST_2
337+ // since they don't have designer permission in the sub folder
338+ listsPage = BeginPage .beginAt (this , SUBFOLDER_B_PATH );
339+ grid = listsPage .getGrid ();
340+ Assert .assertFalse ("Delete button should not be present without designer permission" ,
341+ grid .hasHeaderMenu ("Delete" ));
342+
343+ // From Subfolder A, verify LIST_DESIGNER_USER can delete LIST_1
344+ listsPage = BeginPage .beginAt (this , SUBFOLDER_A_PATH );
345+ grid = listsPage .getGrid ();
346+ selectLists (grid , List .of (LIST_1 .getName ()));
347+ grid .clickHeaderButtonAndWait ("Delete" );
348+ assertTextPresent ("Are you sure you want to delete the following Lists?" );
349+ assertElementPresent (Locator .linkWithText (LIST_1 .getName ()));
350+ clickButton ("Confirm Delete" );
351+
352+ // Verify LIST_1 is deleted successfully
353+ listsPage = BeginPage .beginAt (this , PROJECT_PATH );
354+ grid = listsPage .getGrid ();
355+ Assert .assertFalse ("LIST_1 should no longer exist" ,
356+ grid .getListNames ().contains (LIST_1 .getName ()));
357+ Assert .assertTrue ("LIST_2 should still exist" ,
358+ grid .getListNames ().contains (LIST_2 .getName ()));
359+
360+ // From project folder, verify LIST_DESIGNER_USER can delete LIST_2
361+ selectLists (grid , List .of (LIST_2 .getName ()));
362+ grid .clickHeaderButtonAndWait ("Delete" );
363+ assertTextPresent ("Are you sure you want to delete the following Lists?" );
364+ assertElementPresent (Locator .linkWithText (LIST_2 .getName ()));
365+ clickButton ("Confirm Delete" );
366+
367+ stopImpersonating ();
368+ }
369+
370+ @ Override
371+ protected void doCleanup (boolean afterTest )
372+ {
373+ super .doCleanup (afterTest );
374+ _userHelper .deleteUsers (afterTest , LIST_DESIGNER_USER );
375+ }
376+
377+ @ Override
378+ protected String getProjectName ()
379+ {
380+ return PROJECT_NAME ;
381+ }
382+
383+ @ Override
384+ public List <String > getAssociatedModules ()
385+ {
386+ return List .of ("list" );
387+ }
388+ }
0 commit comments