Skip to content

Commit 6dfa7df

Browse files
committed
Merge branch 'develop' into fb_typeConversionMVTC
2 parents a0c65e8 + d8e56b8 commit 6dfa7df

4 files changed

Lines changed: 125 additions & 19 deletions

File tree

src/org/labkey/test/components/ui/grids/EditableGrid.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ public void waitForReady()
100100
Locators.spinner.waitForElementToDisappear(this, 30000);
101101
}
102102

103+
public static String quoteValues(String delimiter, String... sorted)
104+
{
105+
return Arrays.stream(sorted).map(CSVFormat.DEFAULT::format).collect(Collectors.joining(delimiter));
106+
}
107+
103108
/**
104109
* Quote values to be pasted into lookup columns. Prevents a value containing a comma from being interpreted as
105110
* multiple values.
@@ -108,7 +113,7 @@ public void waitForReady()
108113
*/
109114
public static String quoteForPaste(String... values)
110115
{
111-
return Arrays.stream(values).map(CSVFormat.DEFAULT::format).collect(Collectors.joining(","));
116+
return quoteValues(",", values);
112117
}
113118

114119
public void clickDelete()

src/org/labkey/test/pages/query/UpdateQueryRowPage.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,10 @@ public UpdateQueryRowPage setField(String fieldName, List<String> values)
119119
{
120120
Select field = elementCache().getMultiChoiceSelect(fieldName);
121121
field.deselectAll();
122-
values.forEach(field::selectByVisibleText);
122+
if (values != null && !values.isEmpty())
123+
values.forEach(field::selectByVisibleText);
124+
else
125+
field.selectByIndex(0); // the 1st option is a blank option to remove existing values
123126
return this;
124127
}
125128

src/org/labkey/test/tests/list/ListTest.java

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.labkey.remoteapi.domain.DomainResponse;
2929
import org.labkey.remoteapi.domain.PropertyDescriptor;
3030
import org.labkey.remoteapi.domain.SaveDomainCommand;
31+
import org.labkey.remoteapi.query.ContainerFilter;
3132
import org.labkey.remoteapi.query.Filter;
3233
import org.labkey.serverapi.reader.TabLoader;
3334
import org.labkey.test.BaseWebDriverTest;
@@ -86,7 +87,6 @@
8687
import java.util.List;
8788
import java.util.Map;
8889
import java.util.Set;
89-
import java.util.stream.Collectors;
9090

9191
import static org.junit.Assert.assertEquals;
9292
import static org.junit.Assert.assertFalse;
@@ -1744,42 +1744,132 @@ public void testAutoIncrementKeyEncoded()
17441744
}
17451745

17461746
@Test
1747-
public void testMultiChoiceValues()
1747+
public void testMultiChoiceValues() throws IOException, CommandException
17481748
{
17491749
OptionalFeatureHelper.enableOptionalFeature(getCurrentTest().createDefaultConnection(), "multiChoiceDataType");
17501750
Assume.assumeTrue("Multi-choice text fields are only supported on PostgreSQL", WebTestHelper.getDatabaseType() == WebTestHelper.DatabaseType.PostgreSQL);
1751-
// setup a list with an auto-increment key and multi text choice field
1751+
// Setup a list with an auto-increment key and a multi-value text choice field.
17521752
String encodedListName = TestDataGenerator.randomDomainName("multiChoiceList", DomainUtils.DomainKind.IntList);
17531753
String keyName = TestDataGenerator.randomFieldName("'><script>alert(\":(\")</script>'");
17541754
String columnName = TestDataGenerator.randomFieldName("MultiChoiceField");
1755-
List<String> tcValues = List.of("~`!@#$%^&*()_+=[]{}\\|';:\"<>?,./", "1", "2");
1755+
String plainValue = "1"; // no special characters
1756+
String quotedValue1 = "\"2\""; // literal string: "2" (contains double-quote chars)
1757+
String quotedValue2 = "\"3\""; // literal string: "3"
1758+
List<String> tcValues = List.of("~`!@#$%^&*()_+=[]{}\\|';:\"<>?,./", plainValue, quotedValue1, quotedValue2);
17561759
_listHelper.createList(PROJECT_VERIFY, encodedListName, keyName, col(columnName, ColumnType.MultiValueTextChoice)
17571760
.setMultiChoiceValues(tcValues));
17581761
_listHelper.goToList(encodedListName);
17591762

1763+
// Capture baseline after list creation so the "list created" event is not counted below.
1764+
int baselineRowId = _auditLogHelper.getLatestAuditRowId(AuditLogHelper.AuditEvent.LIST_AUDIT_EVENT.getName());
1765+
17601766
DataRegionTable table = new DataRegionTable("query", getDriver());
1767+
1768+
// --- Insert row 1: single non-quoted value ---
17611769
UpdateQueryRowPage insertNewRow = table.clickInsertNewRow();
1762-
List<String> valuesToChoose = tcValues.subList(1, 3);
1763-
insertNewRow.setField(columnName, valuesToChoose);
1770+
insertNewRow.setField(columnName, List.of(plainValue));
1771+
insertNewRow.submit();
1772+
checker().withScreenshot().verifyEquals("Row 1: display not as expected", plainValue, table.getDataAsText(0, columnName));
1773+
1774+
// --- Insert row 2: single quoted value ---
1775+
insertNewRow = table.clickInsertNewRow();
1776+
insertNewRow.setField(columnName, List.of(quotedValue1));
1777+
insertNewRow.submit();
1778+
// The cell displays the literal string including the double-quote characters.
1779+
checker().withScreenshot().verifyEquals("Row 2: display not as expected", quotedValue1, table.getDataAsText(1, columnName));
1780+
1781+
// --- Insert row 3: mixed (plain + quoted) ---
1782+
insertNewRow = table.clickInsertNewRow();
1783+
insertNewRow.setField(columnName, List.of(plainValue, quotedValue1));
17641784
insertNewRow.submit();
1765-
String expectedList = valuesToChoose.stream()
1766-
.sorted()
1767-
.collect(Collectors.joining(" "));
1768-
checker().withScreenshot().verifyEquals("Multi choice value not as expected", expectedList, table.getDataAsText(0, columnName));
1785+
// MultiChoice.Array sorts case-insensitively; '"' (ASCII 34) < '1' (ASCII 49), so "2" sorts before 1.
1786+
String mixedDisplay = quotedValue1 + " " + plainValue;
1787+
checker().withScreenshot().verifyEquals("Row 3: display not as expected", mixedDisplay, table.getDataAsText(2, columnName));
17691788

1789+
// --- Update row 0: change from plain "1" to quoted "3" ---
17701790
UpdateQueryRowPage editRow = table.clickEditRow(0);
1771-
valuesToChoose = tcValues.subList(1, 3);
1772-
editRow.setField(columnName, valuesToChoose);
1791+
editRow.setField(columnName, List.of(quotedValue2));
17731792
editRow.submit();
1774-
expectedList = valuesToChoose.stream()
1775-
.sorted()
1776-
.collect(Collectors.joining(" "));
1777-
// verify the multi choice value is persisted
1778-
checker().withScreenshot().verifyEquals("Multi choice value not as expected", expectedList, table.getDataAsText(0, columnName));
1793+
checker().withScreenshot().verifyEquals("Row 0 after update: display not as expected",
1794+
quotedValue2, table.getDataAsText(0, columnName));
1795+
1796+
// --- Update row 1: change from quoted value to blank ---
1797+
editRow = table.clickEditRow(1);
1798+
editRow.setField(columnName, List.of());
1799+
editRow.submit();
1800+
checker().withScreenshot().verifyEquals("Row 1 after clearing: display not as expected",
1801+
"", table.getDataAsText(1, columnName));
1802+
1803+
// GitHub Issue 1073: multi-choice values containing quotes were stored as raw
1804+
// PostgreSQL array syntax (e.g. {"2"}) instead of the proper export-encoded format (e.g. """2""").
1805+
List<Map<String, Object>> auditEvents = getListAuditEventsSince(encodedListName, baselineRowId);
1806+
assertEquals("Expected 5 audit events (3 inserts + 2 updates)", 5, auditEvents.size());
1807+
1808+
Set<String> foundInsertAuditValues = new HashSet<>();
1809+
// Track each update as a [oldValue, newValue] pair; order across updates is not guaranteed.
1810+
List<String[]> updateAuditPairs = new ArrayList<>();
1811+
1812+
for (Map<String, Object> event : auditEvents)
1813+
{
1814+
String comment = (String) event.get("Comment");
1815+
String newMapRaw = (String) event.get("NewRecordMap");
1816+
String oldMapRaw = (String) event.get("OldRecordMap");
1817+
1818+
if ("A new list record was inserted".equals(comment) && newMapRaw != null)
1819+
{
1820+
foundInsertAuditValues.add(AuditLogHelper.decodeValues(newMapRaw).get(columnName));
1821+
}
1822+
else if ("An existing list record was modified".equals(comment))
1823+
{
1824+
String oldVal = oldMapRaw != null ? AuditLogHelper.decodeValues(oldMapRaw).get(columnName) : null;
1825+
String newVal = newMapRaw != null ? AuditLogHelper.decodeValues(newMapRaw).get(columnName) : null;
1826+
updateAuditPairs.add(new String[]{oldVal, newVal});
1827+
}
1828+
}
1829+
1830+
// Insert row 1: "1" needs no escaping.
1831+
checker().verifyTrue("Insert row 1: plain value not in audit",
1832+
foundInsertAuditValues.contains(_auditLogHelper.joinMultiChoiceForAudit(plainValue)));
1833+
// Insert row 2: "2" contains double-quotes, escaped as: """2""".
1834+
checker().verifyTrue("Insert row 2: quoted value not in audit",
1835+
foundInsertAuditValues.contains(_auditLogHelper.joinMultiChoiceForAudit(quotedValue1)));
1836+
// Insert row 3: "2" sorts before 1: """2""", 1.
1837+
checker().verifyTrue("Insert row 3: mixed values not in audit",
1838+
foundInsertAuditValues.contains(_auditLogHelper.joinMultiChoiceForAudit(quotedValue1, plainValue)));
1839+
1840+
assertEquals("Expected 2 update audit events", 2, updateAuditPairs.size());
1841+
1842+
// Update row 0: old = "1", new = """3""".
1843+
String expectedUpdate0Old = _auditLogHelper.joinMultiChoiceForAudit(plainValue);
1844+
String expectedUpdate0New = _auditLogHelper.joinMultiChoiceForAudit(quotedValue2);
1845+
checker().verifyTrue("Update row 0: audit pair not found",
1846+
updateAuditPairs.stream().anyMatch(p -> expectedUpdate0Old.equals(p[0]) && expectedUpdate0New.equals(p[1])));
1847+
1848+
// Update row 1: old = """2""", new = null (clearing all selections removes the field from the audit record).
1849+
String expectedUpdate1Old = _auditLogHelper.joinMultiChoiceForAudit(quotedValue1);
1850+
checker().verifyTrue("Update row 1 (remove value): audit pair not found",
1851+
updateAuditPairs.stream().anyMatch(p -> expectedUpdate1Old.equals(p[0]) && p[1] == null));
17791852

17801853
_listHelper.deleteList();
17811854
}
17821855

1856+
private List<Map<String, Object>> getListAuditEventsSince(String listName, int previousRowId)
1857+
throws IOException, CommandException
1858+
{
1859+
List<Filter> filters = List.of(
1860+
new Filter("ListName", listName, Filter.Operator.EQUAL),
1861+
new Filter("RowId", previousRowId, Filter.Operator.GT)
1862+
);
1863+
1864+
return _auditLogHelper.getAuditLogsFromLKS(getProjectName(),
1865+
AuditLogHelper.AuditEvent.LIST_AUDIT_EVENT,
1866+
List.of("RowId", "Comment", "OldRecordMap", "NewRecordMap"),
1867+
filters,
1868+
null,
1869+
ContainerFilter.CurrentAndSubfolders
1870+
).getRows();
1871+
}
1872+
17831873
private List<String> getQueryFormFieldNamesDecoded()
17841874
{
17851875
ArrayList<String> ret = new ArrayList<>();

src/org/labkey/test/util/AuditLogHelper.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.labkey.remoteapi.query.Sort;
1616
import org.labkey.test.Locator;
1717
import org.labkey.test.WebDriverWrapper;
18+
import org.labkey.test.components.ui.grids.EditableGrid;
1819
import org.labkey.test.pages.core.admin.ShowAdminPage;
1920
import org.labkey.test.pages.core.admin.ShowAuditLogPage;
2021

@@ -869,4 +870,11 @@ private Integer getLogColumnIntValue(Map<String, Object> rowEntry, String column
869870
throw new IllegalArgumentException(je);
870871
}
871872
}
873+
874+
// returns the expected audit-log serialization format for multi-choice values
875+
public String joinMultiChoiceForAudit(String... sorted)
876+
{
877+
return EditableGrid.quoteValues(", ", sorted);
878+
}
879+
872880
}

0 commit comments

Comments
 (0)