From 37d941e0b9cd3b09f99ed108e80b641c43c4c189 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 26 Apr 2026 09:34:47 -0700 Subject: [PATCH 1/4] test: cover glossary CSV typed relations across unit, integration, and Playwright Add round-trip and validation coverage for typed glossary term relations (synonym, broader, narrower, custom types from settings) in CSV import/export. Unit tests in CsvUtilTest exercise addTermRelations serialization edge cases. Integration tests in GlossaryCsvRelationTypesIT add a true export-reimport round-trip, mixed-format per-term API verification, asymmetric relation visibility from both sides, and a custom-relation-type round-trip via GlossaryTermRelationSettings. Playwright specs in GlossaryImportExport.spec.ts drive the UI bulk import/export flow with typed relations and assert the validation surface rejects unknown relation types. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../it/tests/GlossaryCsvRelationTypesIT.java | 294 ++++++++++++++++++ .../org/openmetadata/csv/CsvUtilTest.java | 65 ++++ .../e2e/Pages/GlossaryImportExport.spec.ts | 195 ++++++++++++ 3 files changed, 554 insertions(+) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java index 35c24f19264f..d786367738da 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java @@ -18,13 +18,18 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.Execution; @@ -36,6 +41,7 @@ import org.openmetadata.it.util.TestNamespaceExtension; import org.openmetadata.schema.entity.data.Glossary; import org.openmetadata.schema.entity.data.GlossaryTerm; +import org.openmetadata.schema.type.TermRelation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -415,6 +421,294 @@ void testFqnWithColonIsNotMisinterpreted(TestNamespace ns) throws Exception { LOG.debug("FQN with colon handling verified for glossary: {}", glossary.getName()); } + private static final Object SETTINGS_LOCK = new Object(); + + @Test + void testImportPreservesMixedRelationsViaApi(TestNamespace ns) throws Exception { + Glossary glossary = GlossaryTestFactory.createSimple(ns); + GlossaryTerm t1 = GlossaryTermTestFactory.createWithName(ns, glossary, "t1"); + GlossaryTerm t2 = GlossaryTermTestFactory.createWithName(ns, glossary, "t2"); + GlossaryTerm t3 = GlossaryTermTestFactory.createWithName(ns, glossary, "t3"); + + String newTermName = ns.prefix("") + "_mixed"; + String csvContent = + String.format( + "parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension%n" + + ",%s,Mixed,Mixed term,,synonym:%s;%s;narrower:%s,,,,,Draft,,,", + newTermName, + t1.getFullyQualifiedName(), + t2.getFullyQualifiedName(), + t3.getFullyQualifiedName()); + + String result = importGlossaryCsv(glossary.getName(), csvContent, false); + assertNotNull(result); + assertTrue( + result.contains("\"numberOfRowsPassed\":1"), "Expected one row to pass. Result: " + result); + + GlossaryTerm imported = + getGlossaryTerm(glossary.getFullyQualifiedName() + "." + newTermName, "relatedTerms"); + assertNotNull(imported, "Imported term should be retrievable via API"); + assertNotNull(imported.getRelatedTerms(), "Imported term should have related terms"); + assertEquals( + 3, + imported.getRelatedTerms().size(), + "Expected exactly 3 relations. Got: " + imported.getRelatedTerms()); + + Map typeByTermId = + imported.getRelatedTerms().stream() + .collect( + Collectors.toMap( + r -> r.getTerm().getId().toString(), TermRelation::getRelationType)); + assertEquals("synonym", typeByTermId.get(t1.getId().toString()), "t1 should be synonym"); + assertEquals("relatedTo", typeByTermId.get(t2.getId().toString()), "t2 should be relatedTo"); + assertEquals("narrower", typeByTermId.get(t3.getId().toString()), "t3 should be narrower"); + } + + @Test + void testAsymmetricRelationExportShowsBothSides(TestNamespace ns) throws Exception { + Glossary glossary = GlossaryTestFactory.createSimple(ns); + GlossaryTerm parentTerm = GlossaryTermTestFactory.createWithName(ns, glossary, "parentConcept"); + GlossaryTerm childTerm = GlossaryTermTestFactory.createWithName(ns, glossary, "childConcept"); + + addTermRelation(childTerm.getId().toString(), parentTerm.getId().toString(), "broader"); + + String csv = exportGlossaryCsv(glossary.getName()); + LOG.debug("Exported CSV for asymmetric test:\n{}", csv); + + String[] lines = csv.split("\\R"); + String childRow = findRowByTerm(lines, childTerm.getName()); + String parentRow = findRowByTerm(lines, parentTerm.getName()); + assertNotNull(childRow, "Child term row should be in CSV"); + assertNotNull(parentRow, "Parent term row should be in CSV"); + + assertTrue( + childRow.contains("broader:" + parentTerm.getFullyQualifiedName()), + "Child term row should reference parent with 'broader' prefix. Row: " + childRow); + assertTrue( + parentRow.contains("narrower:" + childTerm.getFullyQualifiedName()), + "Parent term row should reference child with 'narrower' prefix (inverse). Row: " + + parentRow); + } + + @Test + void testFullExportReimportPreservesRelationTypes(TestNamespace ns) throws Exception { + Glossary glossary = GlossaryTestFactory.createSimple(ns); + GlossaryTerm t1 = GlossaryTermTestFactory.createWithName(ns, glossary, "alpha"); + GlossaryTerm t2 = GlossaryTermTestFactory.createWithName(ns, glossary, "beta"); + GlossaryTerm t3 = GlossaryTermTestFactory.createWithName(ns, glossary, "gamma"); + GlossaryTerm origin = GlossaryTermTestFactory.createWithName(ns, glossary, "origin"); + + addTermRelation(origin.getId().toString(), t1.getId().toString(), "synonym"); + addTermRelation(origin.getId().toString(), t2.getId().toString(), "broader"); + addTermRelation(origin.getId().toString(), t3.getId().toString(), "relatedTo"); + + String exportedCsv = exportGlossaryCsv(glossary.getName()); + String[] lines = exportedCsv.split("\\R"); + String header = lines[0]; + String originRow = findRowByTerm(lines, origin.getName()); + assertNotNull(originRow, "Origin row should be present in exported CSV"); + + String cloneName = ns.prefix("") + "_clone"; + String clonedRow = originRow.replace("," + origin.getName() + ",", "," + cloneName + ","); + assertFalse( + clonedRow.equals(originRow), + "Replacement should produce a different name; row was: " + originRow); + + String reimportCsv = header + "\r\n" + clonedRow; + String result = importGlossaryCsv(glossary.getName(), reimportCsv, false); + assertNotNull(result); + assertTrue( + result.contains("\"numberOfRowsPassed\":1"), + "Reimport should pass exactly one row. Result: " + result); + + GlossaryTerm clone = + getGlossaryTerm(glossary.getFullyQualifiedName() + "." + cloneName, "relatedTerms"); + assertNotNull(clone, "Cloned term should be retrievable via API"); + assertNotNull(clone.getRelatedTerms(), "Cloned term should have related terms"); + assertEquals( + 3, + clone.getRelatedTerms().size(), + "Cloned term should have 3 relations. Got: " + clone.getRelatedTerms()); + + Map typeByTermId = + clone.getRelatedTerms().stream() + .collect( + Collectors.toMap( + r -> r.getTerm().getId().toString(), TermRelation::getRelationType)); + assertEquals( + "synonym", typeByTermId.get(t1.getId().toString()), "synonym relation should round-trip"); + assertEquals( + "broader", typeByTermId.get(t2.getId().toString()), "broader relation should round-trip"); + assertEquals( + "relatedTo", + typeByTermId.get(t3.getId().toString()), + "relatedTo relation should round-trip"); + } + + @Test + void testRoundTripWithCustomRelationType(TestNamespace ns) throws Exception { + synchronized (SETTINGS_LOCK) { + String customType = "causes" + System.currentTimeMillis(); + String inverseType = "causedBy" + System.currentTimeMillis(); + addCustomRelationTypePair(customType, inverseType); + try { + Glossary glossary = GlossaryTestFactory.createSimple(ns); + GlossaryTerm cause = GlossaryTermTestFactory.createWithName(ns, glossary, "cause"); + GlossaryTerm effect = GlossaryTermTestFactory.createWithName(ns, glossary, "effect"); + + addTermRelation(cause.getId().toString(), effect.getId().toString(), customType); + + String csv = exportGlossaryCsv(glossary.getName()); + String[] lines = csv.split("\\R"); + String causeRow = findRowByTerm(lines, cause.getName()); + assertNotNull(causeRow, "Cause row should be present in exported CSV"); + assertTrue( + causeRow.contains(customType + ":" + effect.getFullyQualifiedName()), + "Cause row should contain '" + customType + ":'. Row: " + causeRow); + + String newName = ns.prefix("") + "_imported"; + String csvImport = + String.format( + "parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension%n" + + ",%s,Imported,via custom type,,%s:%s,,,,,Draft,,,", + newName, customType, effect.getFullyQualifiedName()); + String result = importGlossaryCsv(glossary.getName(), csvImport, false); + assertNotNull(result); + assertTrue( + result.contains("\"numberOfRowsPassed\":1"), + "Import with custom relation type should pass. Result: " + result); + + GlossaryTerm imported = + getGlossaryTerm(glossary.getFullyQualifiedName() + "." + newName, "relatedTerms"); + assertNotNull(imported, "Imported term should be retrievable"); + assertNotNull(imported.getRelatedTerms(), "Imported term should have related terms"); + assertEquals(1, imported.getRelatedTerms().size(), "Expected one custom relation"); + assertEquals( + customType, + imported.getRelatedTerms().get(0).getRelationType(), + "Custom relation type should be preserved through CSV import"); + } finally { + cleanupCustomTypes(customType, inverseType); + } + } + } + + private String findRowByTerm(String[] lines, String termName) { + for (int i = 1; i < lines.length; i++) { + String[] fields = lines[i].split(",", -1); + if (fields.length > 1 && termName.equals(fields[1])) { + return lines[i]; + } + } + return null; + } + + private void addCustomRelationTypePair(String customType, String inverseType) throws Exception { + JsonNode current = getRelationSettings(); + ArrayNode types = (ArrayNode) current.get("config_value").get("relationTypes"); + + ObjectNode forward = OBJECT_MAPPER.createObjectNode(); + forward.put("name", customType); + forward.put("displayName", "Causes"); + forward.put("description", "Test custom relation"); + forward.put("inverseRelation", inverseType); + forward.put("isSymmetric", false); + forward.put("isTransitive", false); + forward.put("isCrossGlossaryAllowed", true); + forward.put("category", "associative"); + forward.put("isSystemDefined", false); + forward.put("color", "#aa00ff"); + types.add(forward); + + ObjectNode inverse = OBJECT_MAPPER.createObjectNode(); + inverse.put("name", inverseType); + inverse.put("displayName", "Caused By"); + inverse.put("description", "Inverse of the test custom relation"); + inverse.put("inverseRelation", customType); + inverse.put("isSymmetric", false); + inverse.put("isTransitive", false); + inverse.put("isCrossGlossaryAllowed", true); + inverse.put("category", "associative"); + inverse.put("isSystemDefined", false); + inverse.put("color", "#ff00aa"); + types.add(inverse); + + ObjectNode payload = OBJECT_MAPPER.createObjectNode(); + payload.set("relationTypes", types); + putRelationSettings(payload); + } + + private void cleanupCustomTypes(String... customTypes) { + try { + JsonNode current = getRelationSettings(); + ArrayNode types = (ArrayNode) current.get("config_value").get("relationTypes"); + ArrayNode filtered = OBJECT_MAPPER.createArrayNode(); + for (JsonNode type : types) { + String name = type.get("name").asText(); + boolean drop = false; + for (String custom : customTypes) { + if (custom.equals(name)) { + drop = true; + break; + } + } + if (!drop) { + filtered.add(type); + } + } + ObjectNode payload = OBJECT_MAPPER.createObjectNode(); + payload.set("relationTypes", filtered); + putRelationSettings(payload); + } catch (Exception e) { + LOG.warn( + "Failed to cleanup custom relation types {}: {}", List.of(customTypes), e.getMessage()); + } + } + + private JsonNode getRelationSettings() throws Exception { + String baseUrl = SdkClients.getServerUrl(); + String token = SdkClients.getAdminToken(); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/v1/system/settings/glossaryTermRelationSettings")) + .header("Authorization", "Bearer " + token) + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException("Failed to read settings: " + response.body()); + } + return OBJECT_MAPPER.readTree(response.body()); + } + + private void putRelationSettings(ObjectNode configValue) throws Exception { + String baseUrl = SdkClients.getServerUrl(); + String token = SdkClients.getAdminToken(); + ObjectNode payload = OBJECT_MAPPER.createObjectNode(); + payload.put("config_type", "glossaryTermRelationSettings"); + payload.set("config_value", configValue); + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/v1/system/settings")) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(30)) + .PUT(HttpRequest.BodyPublishers.ofString(OBJECT_MAPPER.writeValueAsString(payload))) + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException( + "Failed to update settings: status=" + + response.statusCode() + + ", body=" + + response.body()); + } + } + private GlossaryTerm addTermRelation(String fromTermId, String toTermId, String relationType) throws Exception { String baseUrl = SdkClients.getServerUrl(); diff --git a/openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java b/openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java index 74b2b8d8a66e..677882802a46 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java @@ -31,6 +31,7 @@ import org.openmetadata.schema.entity.type.CustomProperty; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.TagLabel; +import org.openmetadata.schema.type.TermRelation; public class CsvUtilTest { @Test @@ -243,6 +244,70 @@ void testAddExtensionFormatsStructuredValues() { assertTrue(extensionField.contains("delta,with,comma")); } + @Test + void testAddTermRelationsHandlesNullAndEmptyInputs() { + List csvRecord = new ArrayList<>(); + CsvUtil.addTermRelations(csvRecord, null); + assertEquals(Collections.singletonList(null), csvRecord); + + csvRecord = new ArrayList<>(); + CsvUtil.addTermRelations(csvRecord, Collections.emptyList()); + assertEquals(Collections.singletonList(null), csvRecord); + } + + @Test + void testAddTermRelationsOmitsRelatedToPrefix() { + List csvRecord = new ArrayList<>(); + CsvUtil.addTermRelations( + csvRecord, + List.of( + new TermRelation() + .withRelationType("relatedTo") + .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Alpha")))); + assertEquals(List.of("Glossary.Alpha"), csvRecord); + } + + @Test + void testAddTermRelationsTreatsNullRelationTypeAsRelatedTo() { + List csvRecord = new ArrayList<>(); + CsvUtil.addTermRelations( + csvRecord, + List.of( + new TermRelation() + .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Alpha")))); + assertEquals(List.of("Glossary.Alpha"), csvRecord); + } + + @Test + void testAddTermRelationsEmitsPrefixForNonDefaultType() { + List csvRecord = new ArrayList<>(); + CsvUtil.addTermRelations( + csvRecord, + List.of( + new TermRelation() + .withRelationType("synonym") + .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Alpha")))); + assertEquals(List.of("synonym:Glossary.Alpha"), csvRecord); + } + + @Test + void testAddTermRelationsSortsAndMixesTypes() { + List csvRecord = new ArrayList<>(); + CsvUtil.addTermRelations( + csvRecord, + List.of( + new TermRelation() + .withRelationType("synonym") + .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Zeta")), + new TermRelation() + .withRelationType("relatedTo") + .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Alpha")), + new TermRelation() + .withRelationType("broader") + .withTerm(new EntityReference().withFullyQualifiedName("Glossary.Beta")))); + assertEquals(List.of("Glossary.Alpha;broader:Glossary.Beta;synonym:Glossary.Zeta"), csvRecord); + } + public static void assertCsv(String expectedCsv, String actualCsv) { // Break a csv text into records, sort it and compare List expectedCsvRecords = listOf(expectedCsv.split(CsvUtil.LINE_SEPARATOR)); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts index 92ba4fd5b490..44d3dd527236 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts @@ -723,4 +723,199 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,

Self-referential term

{ + const { apiContext, afterAction } = await getApiContext(page); + const relGlossary = new Glossary('TypedRelations'); + const target1 = new GlossaryTerm(relGlossary, undefined, 'TR_target1'); + const target2 = new GlossaryTerm(relGlossary, undefined, 'TR_target2'); + const target3 = new GlossaryTerm(relGlossary, undefined, 'TR_target3'); + + try { + await test.step('Create glossary and three target terms', async () => { + await relGlossary.create(apiContext); + await target1.create(apiContext); + await target2.create(apiContext); + await target3.create(apiContext); + }); + + const importedTermName = `TR_imported_${uuid()}`; + const importedTermFqn = `${relGlossary.data.name}.${importedTermName}`; + + await test.step('Import CSV with synonym/relatedTo/narrower mix', async () => { + await sidebarClick(page, SidebarItem.GLOSSARY); + await selectActiveGlossary(page, relGlossary.data.displayName); + + await page.click('[data-testid="manage-button"]'); + await page.click('[data-testid="import-button-description"]'); + + const csvContent = + `parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension\n` + + `,${importedTermName},${importedTermName},Imported,,` + + `synonym:${target1.responseData.fullyQualifiedName};` + + `${target2.responseData.fullyQualifiedName};` + + `narrower:${target3.responseData.fullyQualifiedName},,,,user:admin,Approved,,,`; + + await page.locator('[type="file"]').waitFor({ state: 'attached' }); + await page.setInputFiles('[type="file"]', { + name: 'typed-relations.csv', + mimeType: 'text/csv', + buffer: Buffer.from(csvContent), + }); + + await page + .getByTestId('upload-file-widget') + .waitFor({ state: 'hidden' }); + + await expect(page.locator('.rdg-header-row')).toBeVisible(); + await page.getByRole('button', { name: 'Next' }).click(); + + const loader = page.locator( + '.inovua-react-toolkit-load-mask__background-layer' + ); + + await loader.waitFor({ state: 'hidden' }); + + await validateImportStatus(page, { + passed: '1', + processed: '1', + failed: '0', + }); + + await page.getByRole('button', { name: 'Update' }).click(); + await loader.waitFor({ state: 'detached' }); + }); + + await test.step('Verify each relation type via API', async () => { + const response = await apiContext.get( + `/api/v1/glossaryTerms/name/${encodeURIComponent( + importedTermFqn + )}?fields=relatedTerms` + ); + + expect(response.status()).toBe(200); + const term = await response.json(); + expect(term.relatedTerms).toHaveLength(3); + + const typeByFqn: Record = {}; + for (const rel of term.relatedTerms) { + typeByFqn[rel.term.fullyQualifiedName] = rel.relationType; + } + + expect(typeByFqn[target1.responseData.fullyQualifiedName]).toBe( + 'synonym' + ); + expect(typeByFqn[target2.responseData.fullyQualifiedName]).toBe( + 'relatedTo' + ); + expect(typeByFqn[target3.responseData.fullyQualifiedName]).toBe( + 'narrower' + ); + }); + + await test.step('Export and verify CSV emits relation type prefixes', async () => { + await sidebarClick(page, SidebarItem.GLOSSARY); + await selectActiveGlossary(page, relGlossary.data.displayName); + + await page.click('[data-testid="manage-button"]'); + await page.click('[data-testid="export-button-description"]'); + await page.locator('[role="dialog"]').waitFor(); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export' }).click(); + const download = await downloadPromise; + + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + + const csvContent = Buffer.concat(chunks).toString('utf-8'); + const lines = csvContent.split(/\r?\n/); + const importedRow = lines.find((line) => + line.includes(`,${importedTermName},`) + ); + + expect(importedRow).toBeDefined(); + expect(importedRow).toContain( + `synonym:${target1.responseData.fullyQualifiedName}` + ); + expect(importedRow).toContain( + `narrower:${target3.responseData.fullyQualifiedName}` + ); + // relatedTo entries are emitted without a prefix. + expect(importedRow).toContain(target2.responseData.fullyQualifiedName); + }); + } finally { + await relGlossary.delete(apiContext); + await afterAction(); + } + }); + + test('Glossary CSV import rejects unknown relation type', async ({ + page, + }) => { + const { apiContext, afterAction } = await getApiContext(page); + const relGlossary = new Glossary('TypedRelationsInvalid'); + const target = new GlossaryTerm(relGlossary, undefined, 'TR_targetX'); + + try { + await test.step('Create glossary and target term', async () => { + await relGlossary.create(apiContext); + await target.create(apiContext); + }); + + await test.step('Import CSV with invalid relation type and assert failure', async () => { + await sidebarClick(page, SidebarItem.GLOSSARY); + await selectActiveGlossary(page, relGlossary.data.displayName); + + await page.click('[data-testid="manage-button"]'); + await page.click('[data-testid="import-button-description"]'); + + const newTermName = `TR_invalid_${uuid()}`; + const csvContent = + `parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension\n` + + `,${newTermName},${newTermName},Invalid,,` + + `notarealtype:${target.responseData.fullyQualifiedName},,,,user:admin,Approved,,,`; + + await page.locator('[type="file"]').waitFor({ state: 'attached' }); + await page.setInputFiles('[type="file"]', { + name: 'invalid-relation.csv', + mimeType: 'text/csv', + buffer: Buffer.from(csvContent), + }); + + await page + .getByTestId('upload-file-widget') + .waitFor({ state: 'hidden' }); + + await expect(page.locator('.rdg-header-row')).toBeVisible(); + await page.getByRole('button', { name: 'Next' }).click(); + + const loader = page.locator( + '.inovua-react-toolkit-load-mask__background-layer' + ); + + await loader.waitFor({ state: 'hidden' }); + + await validateImportStatus(page, { + passed: '0', + processed: '1', + failed: '1', + }); + + const firstRow = page.locator('.rdg-row').first(); + const errorText = await firstRow + .locator('.rdg-cell-details') + .textContent(); + + expect(errorText).toContain('Invalid relation type'); + }); + } finally { + await relGlossary.delete(apiContext); + await afterAction(); + } + }); }); From af8bc85f9f82e27c8e6c2ad2d896befa76db418e Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Sun, 26 Apr 2026 10:37:31 -0700 Subject: [PATCH 2/4] test(glossary-csv): address review feedback on typed-relation tests - Replace naive comma split in findRowByTerm with Apache Commons CSV parser so quoted/escaped fields don't shift column indices. - Replace class-local SETTINGS_LOCK with JUnit @ResourceLock keyed on "glossaryTermRelationSettings" so settings mutations serialise across test classes, and document the contract for future authors. - Add uuid() suffixes to Playwright glossary names to avoid collisions under fullyParallel test execution. - Guard download.createReadStream() against null and use a waiting toContainText assertion on the error cell so a missing selector surfaces a clearer failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../it/tests/GlossaryCsvRelationTypesIT.java | 123 ++++++++++-------- .../e2e/Pages/GlossaryImportExport.spec.ts | 49 +++++-- 2 files changed, 105 insertions(+), 67 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java index d786367738da..9c1f07f34e8f 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.StringReader; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -30,10 +31,15 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; import org.openmetadata.it.factories.GlossaryTermTestFactory; import org.openmetadata.it.factories.GlossaryTestFactory; import org.openmetadata.it.util.SdkClients; @@ -421,7 +427,12 @@ void testFqnWithColonIsNotMisinterpreted(TestNamespace ns) throws Exception { LOG.debug("FQN with colon handling verified for glossary: {}", glossary.getName()); } - private static final Object SETTINGS_LOCK = new Object(); + /** + * Shared resource key for the global {@code glossaryTermRelationSettings} endpoint. Any IT class + * that mutates these settings must use the same key on a {@link ResourceLock} so JUnit serialises + * across classes; a class-local synchronized block would only guard intra-class concurrency. + */ + private static final String SETTINGS_RESOURCE_KEY = "glossaryTermRelationSettings"; @Test void testImportPreservesMixedRelationsViaApi(TestNamespace ns) throws Exception { @@ -475,9 +486,8 @@ void testAsymmetricRelationExportShowsBothSides(TestNamespace ns) throws Excepti String csv = exportGlossaryCsv(glossary.getName()); LOG.debug("Exported CSV for asymmetric test:\n{}", csv); - String[] lines = csv.split("\\R"); - String childRow = findRowByTerm(lines, childTerm.getName()); - String parentRow = findRowByTerm(lines, parentTerm.getName()); + String childRow = findRowByTerm(csv, childTerm.getName()); + String parentRow = findRowByTerm(csv, parentTerm.getName()); assertNotNull(childRow, "Child term row should be in CSV"); assertNotNull(parentRow, "Parent term row should be in CSV"); @@ -505,7 +515,7 @@ void testFullExportReimportPreservesRelationTypes(TestNamespace ns) throws Excep String exportedCsv = exportGlossaryCsv(glossary.getName()); String[] lines = exportedCsv.split("\\R"); String header = lines[0]; - String originRow = findRowByTerm(lines, origin.getName()); + String originRow = findRowByTerm(exportedCsv, origin.getName()); assertNotNull(originRow, "Origin row should be present in exported CSV"); String cloneName = ns.prefix("") + "_clone"; @@ -546,58 +556,65 @@ void testFullExportReimportPreservesRelationTypes(TestNamespace ns) throws Excep } @Test + @ResourceLock(value = SETTINGS_RESOURCE_KEY, mode = ResourceAccessMode.READ_WRITE) void testRoundTripWithCustomRelationType(TestNamespace ns) throws Exception { - synchronized (SETTINGS_LOCK) { - String customType = "causes" + System.currentTimeMillis(); - String inverseType = "causedBy" + System.currentTimeMillis(); - addCustomRelationTypePair(customType, inverseType); - try { - Glossary glossary = GlossaryTestFactory.createSimple(ns); - GlossaryTerm cause = GlossaryTermTestFactory.createWithName(ns, glossary, "cause"); - GlossaryTerm effect = GlossaryTermTestFactory.createWithName(ns, glossary, "effect"); - - addTermRelation(cause.getId().toString(), effect.getId().toString(), customType); - - String csv = exportGlossaryCsv(glossary.getName()); - String[] lines = csv.split("\\R"); - String causeRow = findRowByTerm(lines, cause.getName()); - assertNotNull(causeRow, "Cause row should be present in exported CSV"); - assertTrue( - causeRow.contains(customType + ":" + effect.getFullyQualifiedName()), - "Cause row should contain '" + customType + ":'. Row: " + causeRow); - - String newName = ns.prefix("") + "_imported"; - String csvImport = - String.format( - "parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension%n" - + ",%s,Imported,via custom type,,%s:%s,,,,,Draft,,,", - newName, customType, effect.getFullyQualifiedName()); - String result = importGlossaryCsv(glossary.getName(), csvImport, false); - assertNotNull(result); - assertTrue( - result.contains("\"numberOfRowsPassed\":1"), - "Import with custom relation type should pass. Result: " + result); - - GlossaryTerm imported = - getGlossaryTerm(glossary.getFullyQualifiedName() + "." + newName, "relatedTerms"); - assertNotNull(imported, "Imported term should be retrievable"); - assertNotNull(imported.getRelatedTerms(), "Imported term should have related terms"); - assertEquals(1, imported.getRelatedTerms().size(), "Expected one custom relation"); - assertEquals( - customType, - imported.getRelatedTerms().get(0).getRelationType(), - "Custom relation type should be preserved through CSV import"); - } finally { - cleanupCustomTypes(customType, inverseType); - } + String customType = "causes" + System.currentTimeMillis(); + String inverseType = "causedBy" + System.currentTimeMillis(); + addCustomRelationTypePair(customType, inverseType); + try { + Glossary glossary = GlossaryTestFactory.createSimple(ns); + GlossaryTerm cause = GlossaryTermTestFactory.createWithName(ns, glossary, "cause"); + GlossaryTerm effect = GlossaryTermTestFactory.createWithName(ns, glossary, "effect"); + + addTermRelation(cause.getId().toString(), effect.getId().toString(), customType); + + String csv = exportGlossaryCsv(glossary.getName()); + String causeRow = findRowByTerm(csv, cause.getName()); + assertNotNull(causeRow, "Cause row should be present in exported CSV"); + assertTrue( + causeRow.contains(customType + ":" + effect.getFullyQualifiedName()), + "Cause row should contain '" + customType + ":'. Row: " + causeRow); + + String newName = ns.prefix("") + "_imported"; + String csvImport = + String.format( + "parent,name*,displayName,description,synonyms,relatedTerms,references,tags,reviewers,owner,glossaryStatus,color,iconURL,extension%n" + + ",%s,Imported,via custom type,,%s:%s,,,,,Draft,,,", + newName, customType, effect.getFullyQualifiedName()); + String result = importGlossaryCsv(glossary.getName(), csvImport, false); + assertNotNull(result); + assertTrue( + result.contains("\"numberOfRowsPassed\":1"), + "Import with custom relation type should pass. Result: " + result); + + GlossaryTerm imported = + getGlossaryTerm(glossary.getFullyQualifiedName() + "." + newName, "relatedTerms"); + assertNotNull(imported, "Imported term should be retrievable"); + assertNotNull(imported.getRelatedTerms(), "Imported term should have related terms"); + assertEquals(1, imported.getRelatedTerms().size(), "Expected one custom relation"); + assertEquals( + customType, + imported.getRelatedTerms().get(0).getRelationType(), + "Custom relation type should be preserved through CSV import"); + } finally { + cleanupCustomTypes(customType, inverseType); } } - private String findRowByTerm(String[] lines, String termName) { - for (int i = 1; i < lines.length; i++) { - String[] fields = lines[i].split(",", -1); - if (fields.length > 1 && termName.equals(fields[1])) { - return lines[i]; + /** + * Locate a CSV row by its glossary-term name. Uses Apache Commons CSV so quoted/escaped fields + * (e.g. a deeply nested parent FQN containing a comma) don't shift column indices and break the + * lookup. Returns the original line text so callers can run substring assertions against it. + */ + private String findRowByTerm(String csvContent, String termName) throws Exception { + String[] lines = csvContent.split("\\R"); + try (CSVParser parser = + CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(new StringReader(csvContent))) { + for (CSVRecord record : parser) { + if (termName.equals(record.get("name*"))) { + int lineIndex = Math.toIntExact(record.getRecordNumber()); + return lineIndex < lines.length ? lines[lineIndex] : null; + } } } return null; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts index 44d3dd527236..5393a77b2491 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts @@ -726,10 +726,23 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,

Self-referential term

{ const { apiContext, afterAction } = await getApiContext(page); - const relGlossary = new Glossary('TypedRelations'); - const target1 = new GlossaryTerm(relGlossary, undefined, 'TR_target1'); - const target2 = new GlossaryTerm(relGlossary, undefined, 'TR_target2'); - const target3 = new GlossaryTerm(relGlossary, undefined, 'TR_target3'); + const suffix = uuid(); + const relGlossary = new Glossary(`TypedRelations_${suffix}`); + const target1 = new GlossaryTerm( + relGlossary, + undefined, + `TR_target1_${suffix}` + ); + const target2 = new GlossaryTerm( + relGlossary, + undefined, + `TR_target2_${suffix}` + ); + const target3 = new GlossaryTerm( + relGlossary, + undefined, + `TR_target3_${suffix}` + ); try { await test.step('Create glossary and three target terms', async () => { @@ -826,10 +839,13 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,

Self-referential term

Self-referential term

{ const { apiContext, afterAction } = await getApiContext(page); - const relGlossary = new Glossary('TypedRelationsInvalid'); - const target = new GlossaryTerm(relGlossary, undefined, 'TR_targetX'); + const suffix = uuid(); + const relGlossary = new Glossary(`TypedRelationsInvalid_${suffix}`); + const target = new GlossaryTerm( + relGlossary, + undefined, + `TR_targetX_${suffix}` + ); try { await test.step('Create glossary and target term', async () => { @@ -906,12 +927,12 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,

Self-referential term

Date: Sun, 26 Apr 2026 20:30:00 -0700 Subject: [PATCH 3/4] test(rdf-it): mark RDF-mutating glossary ITs @Isolated to stop cross-class flakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlossaryOntologyExportIT and GlossaryTermRelationIT both flip global RdfConfiguration in @BeforeAll and write synchronously to a Fuseki backend. They were marked @Execution(ExecutionMode.SAME_THREAD), which only serialises within the class — other test classes still run concurrently, inherit the enabled RDF state, and contend for the same Fuseki backend, causing the export request to time out at 60s. Add @Isolated so JUnit 5 ensures no other class runs while these are running. This matches the pattern RdfResourceIT already uses for the same reason. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../openmetadata/it/tests/GlossaryOntologyExportIT.java | 7 ++++++- .../org/openmetadata/it/tests/GlossaryTermRelationIT.java | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java index 8a2f50f062af..7d12aaa59223 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.api.parallel.Isolated; import org.openmetadata.it.bootstrap.TestSuiteBootstrap; import org.openmetadata.it.factories.GlossaryTermTestFactory; import org.openmetadata.it.factories.GlossaryTestFactory; @@ -43,8 +44,12 @@ * *

Parallelization: Runs with @Execution(ExecutionMode.SAME_THREAD) because each test * blocks a server thread on synchronous Fuseki writes; concurrent execution can exhaust the - * server thread pool and cause request timeouts. + * server thread pool and cause request timeouts. The class is also @Isolated because the + * @BeforeAll hook flips global RDF configuration to a test Fuseki container — any other + * test class running concurrently would inherit that state and contend for the same backend + * (matches the pattern used by RdfResourceIT for the same reason). */ +@Isolated @Execution(ExecutionMode.SAME_THREAD) @ExtendWith(TestNamespaceExtension.class) public class GlossaryOntologyExportIT { diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationIT.java index 436b0dbb6163..cf2430125b15 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermRelationIT.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.api.parallel.Isolated; import org.openmetadata.it.bootstrap.TestSuiteBootstrap; import org.openmetadata.it.util.SdkClients; import org.openmetadata.schema.api.configuration.rdf.RdfConfiguration; @@ -45,7 +46,11 @@ * *

These tests verify that typed semantic relationships between glossary terms (e.g., * calculatedFrom, synonym, broader) are correctly stored and returned by the API. + * + *

@Isolated because @BeforeAll flips global RDF configuration; any concurrent class + * would inherit that state and contend for the shared Fuseki backend, causing flaky timeouts. */ +@Isolated @Execution(ExecutionMode.SAME_THREAD) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class GlossaryTermRelationIT { From 5d12c9ffd0de02cb0314e77f607254a0261cfe0d Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 29 Apr 2026 09:51:12 -0700 Subject: [PATCH 4/4] test(glossary-csv): address Copilot review feedback on typed-relation tests - Replace Collectors.toMap with imperative HashMap.put when grouping TermRelations by id so a regression that drops relationType surfaces as a clear assertEquals(null vs expected) instead of a NullPointerException buried inside the collector. - Reconstruct findRowByTerm output via CSVFormat.format(record.values()) rather than indexing csvContent.split("\\R") with record.getRecordNumber(); recordNumber is a record counter, not a physical line index, so blank lines or embedded newlines would have offset the lookup. - Add closeFirstPopupAlert(page) after selectActiveGlossary in both typed-relation Playwright specs to match the existing Bulk Import Export guard against the intermittent "glossary not found" popup under parallel execution. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../it/tests/GlossaryCsvRelationTypesIT.java | 29 +++++++++---------- .../e2e/Pages/GlossaryImportExport.spec.ts | 4 +++ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java index 9c1f07f34e8f..2fac00477693 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryCsvRelationTypesIT.java @@ -28,9 +28,9 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; @@ -465,11 +465,10 @@ void testImportPreservesMixedRelationsViaApi(TestNamespace ns) throws Exception imported.getRelatedTerms().size(), "Expected exactly 3 relations. Got: " + imported.getRelatedTerms()); - Map typeByTermId = - imported.getRelatedTerms().stream() - .collect( - Collectors.toMap( - r -> r.getTerm().getId().toString(), TermRelation::getRelationType)); + Map typeByTermId = new HashMap<>(); + for (TermRelation r : imported.getRelatedTerms()) { + typeByTermId.put(r.getTerm().getId().toString(), r.getRelationType()); + } assertEquals("synonym", typeByTermId.get(t1.getId().toString()), "t1 should be synonym"); assertEquals("relatedTo", typeByTermId.get(t2.getId().toString()), "t2 should be relatedTo"); assertEquals("narrower", typeByTermId.get(t3.getId().toString()), "t3 should be narrower"); @@ -540,11 +539,10 @@ void testFullExportReimportPreservesRelationTypes(TestNamespace ns) throws Excep clone.getRelatedTerms().size(), "Cloned term should have 3 relations. Got: " + clone.getRelatedTerms()); - Map typeByTermId = - clone.getRelatedTerms().stream() - .collect( - Collectors.toMap( - r -> r.getTerm().getId().toString(), TermRelation::getRelationType)); + Map typeByTermId = new HashMap<>(); + for (TermRelation r : clone.getRelatedTerms()) { + typeByTermId.put(r.getTerm().getId().toString(), r.getRelationType()); + } assertEquals( "synonym", typeByTermId.get(t1.getId().toString()), "synonym relation should round-trip"); assertEquals( @@ -603,17 +601,16 @@ void testRoundTripWithCustomRelationType(TestNamespace ns) throws Exception { /** * Locate a CSV row by its glossary-term name. Uses Apache Commons CSV so quoted/escaped fields - * (e.g. a deeply nested parent FQN containing a comma) don't shift column indices and break the - * lookup. Returns the original line text so callers can run substring assertions against it. + * (commas, embedded newlines, etc.) don't shift column indices and break the lookup. Returns a + * normalized CSV row reconstructed from the parsed record so callers can run substring + * assertions without relying on physical line numbers. */ private String findRowByTerm(String csvContent, String termName) throws Exception { - String[] lines = csvContent.split("\\R"); try (CSVParser parser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(new StringReader(csvContent))) { for (CSVRecord record : parser) { if (termName.equals(record.get("name*"))) { - int lineIndex = Math.toIntExact(record.getRecordNumber()); - return lineIndex < lines.length ? lines[lineIndex] : null; + return CSVFormat.DEFAULT.format((Object[]) record.values()); } } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts index 5393a77b2491..f3675d18d938 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts @@ -758,6 +758,8 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,

Self-referential term

{ await sidebarClick(page, SidebarItem.GLOSSARY); await selectActiveGlossary(page, relGlossary.data.displayName); + // Safety check: parallel test runs can surface a "glossary not found" popup. + await closeFirstPopupAlert(page); await page.click('[data-testid="manage-button"]'); await page.click('[data-testid="import-button-description"]'); @@ -829,6 +831,7 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,

Self-referential term

{ await sidebarClick(page, SidebarItem.GLOSSARY); await selectActiveGlossary(page, relGlossary.data.displayName); + await closeFirstPopupAlert(page); await page.click('[data-testid="manage-button"]'); await page.click('[data-testid="export-button-description"]'); @@ -891,6 +894,7 @@ ${partialGlossary.data.name}.selfRef,selfRef,selfRef,

Self-referential term

{ await sidebarClick(page, SidebarItem.GLOSSARY); await selectActiveGlossary(page, relGlossary.data.displayName); + await closeFirstPopupAlert(page); await page.click('[data-testid="manage-button"]'); await page.click('[data-testid="import-button-description"]');