|
28 | 28 | import org.labkey.remoteapi.domain.DomainResponse; |
29 | 29 | import org.labkey.remoteapi.domain.PropertyDescriptor; |
30 | 30 | import org.labkey.remoteapi.domain.SaveDomainCommand; |
| 31 | +import org.labkey.remoteapi.query.ContainerFilter; |
31 | 32 | import org.labkey.remoteapi.query.Filter; |
32 | 33 | import org.labkey.serverapi.reader.TabLoader; |
33 | 34 | import org.labkey.test.BaseWebDriverTest; |
|
86 | 87 | import java.util.List; |
87 | 88 | import java.util.Map; |
88 | 89 | import java.util.Set; |
89 | | -import java.util.stream.Collectors; |
90 | 90 |
|
91 | 91 | import static org.junit.Assert.assertEquals; |
92 | 92 | import static org.junit.Assert.assertFalse; |
@@ -1744,42 +1744,132 @@ public void testAutoIncrementKeyEncoded() |
1744 | 1744 | } |
1745 | 1745 |
|
1746 | 1746 | @Test |
1747 | | - public void testMultiChoiceValues() |
| 1747 | + public void testMultiChoiceValues() throws IOException, CommandException |
1748 | 1748 | { |
1749 | 1749 | OptionalFeatureHelper.enableOptionalFeature(getCurrentTest().createDefaultConnection(), "multiChoiceDataType"); |
1750 | 1750 | 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. |
1752 | 1752 | String encodedListName = TestDataGenerator.randomDomainName("multiChoiceList", DomainUtils.DomainKind.IntList); |
1753 | 1753 | String keyName = TestDataGenerator.randomFieldName("'><script>alert(\":(\")</script>'"); |
1754 | 1754 | 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); |
1756 | 1759 | _listHelper.createList(PROJECT_VERIFY, encodedListName, keyName, col(columnName, ColumnType.MultiValueTextChoice) |
1757 | 1760 | .setMultiChoiceValues(tcValues)); |
1758 | 1761 | _listHelper.goToList(encodedListName); |
1759 | 1762 |
|
| 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 | + |
1760 | 1766 | DataRegionTable table = new DataRegionTable("query", getDriver()); |
| 1767 | + |
| 1768 | + // --- Insert row 1: single non-quoted value --- |
1761 | 1769 | 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)); |
1764 | 1784 | 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)); |
1769 | 1788 |
|
| 1789 | + // --- Update row 0: change from plain "1" to quoted "3" --- |
1770 | 1790 | UpdateQueryRowPage editRow = table.clickEditRow(0); |
1771 | | - valuesToChoose = tcValues.subList(1, 3); |
1772 | | - editRow.setField(columnName, valuesToChoose); |
| 1791 | + editRow.setField(columnName, List.of(quotedValue2)); |
1773 | 1792 | 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)); |
1779 | 1852 |
|
1780 | 1853 | _listHelper.deleteList(); |
1781 | 1854 | } |
1782 | 1855 |
|
| 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 | + |
1783 | 1873 | private List<String> getQueryFormFieldNamesDecoded() |
1784 | 1874 | { |
1785 | 1875 | ArrayList<String> ret = new ArrayList<>(); |
|
0 commit comments