diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/util/DocLevelMonitorQueries.kt b/alerting/src/main/kotlin/org/opensearch/alerting/util/DocLevelMonitorQueries.kt index 455d9e5ec..3d29c801a 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/util/DocLevelMonitorQueries.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/util/DocLevelMonitorQueries.kt @@ -64,6 +64,23 @@ class DocLevelMonitorQueries(private val client: Client, private val clusterServ const val TYPE = "type" const val INDEX_PATTERN_SUFFIX = "-000001" const val QUERY_INDEX_BASE_FIELDS_COUNT = 8 // 3 fields we defined and 5 builtin additional metadata fields + + /** + * Returns a mutable deep copy of [map] so that callers can mutate the result + * without affecting the original (e.g. cached cluster-state data). + */ + @Suppress("UNCHECKED_CAST") + fun deepCopyMap(map: Map): MutableMap = + map.entries.associate { (k, v) -> + k to when (v) { + is Map<*, *> -> deepCopyMap(v as Map) + is List<*> -> (v as List).map { elem -> + if (elem is Map<*, *>) deepCopyMap(elem as Map) else elem + }.toMutableList() + else -> v + } + }.toMutableMap() + @JvmStatic fun docLevelQueriesMappings(): String { return DocLevelMonitorQueries::class.java.classLoader.getResource("mappings/doc-level-queries.json").readText() @@ -297,15 +314,18 @@ class DocLevelMonitorQueries(private val client: Client, private val clusterServ if (clusterState.routingTable.hasIndex(concreteIndexName)) { val indexMetadata = clusterState.metadata.index(concreteIndexName) if (indexMetadata.mapping()?.sourceAsMap?.get("properties") != null) { - val properties = ( - (indexMetadata.mapping()?.sourceAsMap?.get("properties")) - as MutableMap - ) + @Suppress("UNCHECKED_CAST") + val properties = deepCopyMap( + indexMetadata.mapping()?.sourceAsMap?.get("properties") as Map + ) // Node processor function is used to process leaves of index mappings tree // val leafNodeProcessor = fun(fieldName: String, fullPath: String, props: MutableMap): Triple> { + if (props["type"] == "alias") { + return Triple(fieldName, fieldName, mutableMapOf()) + } val newProps = props.toMutableMap() if (monitor.dataSources.queryIndexMappingsByType.isNotEmpty()) { val mappingsByType = monitor.dataSources.queryIndexMappingsByType @@ -331,7 +351,9 @@ class DocLevelMonitorQueries(private val client: Client, private val clusterServ // Traverse and update index mappings here while extracting flatten field paths val flattenPaths = mutableMapOf>() traverseMappingsAndUpdate(properties, "", leafNodeProcessor, flattenPaths) - flattenPaths.keys.forEach { allFlattenPaths.add(Pair(it, concreteIndexName)) } + flattenPaths.entries + .filter { (_, props) -> props["type"] != "alias" } + .forEach { (path, _) -> allFlattenPaths.add(Pair(path, concreteIndexName)) } // Updated mappings ready to be applied on queryIndex properties.forEach { if ( @@ -339,6 +361,13 @@ class DocLevelMonitorQueries(private val client: Client, private val clusterServ (it.value as Map).containsKey("type") && (it.value as Map)["type"] == NESTED ) { + } else if ( + it.value is Map<*, *> && + ( + (it.value as Map)["type"] == "alias" || + (it.value as Map).isEmpty() + ) + ) { } else { if (updatedProperties.containsKey(it.key) && updatedProperties[it.key] != it.value) { val mergedField = mergeConflictingFields( @@ -526,7 +555,7 @@ class DocLevelMonitorQueries(private val client: Client, private val clusterServ return Pair(updateMappingResponse, targetQueryIndex) } catch (e: Exception) { val unwrappedException = ExceptionsHelper.unwrapCause(e) as Exception - log.debug("exception after rollover queryIndex index: $targetQueryIndex exception: ${unwrappedException.message}") + log.warn("PUT mapping failed on queryIndex $targetQueryIndex [${unwrappedException.javaClass.simpleName}]: ${unwrappedException.message}") // If we reached limit for total number of fields in mappings, do a rollover here if (unwrappedException.message?.contains("Limit of total fields") == true) { try { @@ -556,6 +585,20 @@ class DocLevelMonitorQueries(private val client: Client, private val clusterServ } } } else { + // Fast-fail on structural mapping errors that index recreation cannot fix. + val isUnrecoverableMappingError = unwrappedException.message?.let { + it.contains("alias must refer to an existing field") || + (it.contains("mapper [") && it.contains("cannot be changed from type")) + } ?: false + + if (isUnrecoverableMappingError) { + log.error( + "Unrecoverable mapping error on queryIndex $targetQueryIndex — " + + "retry will not help: ${unwrappedException.message}" + ) + throw AlertingException.wrap(unwrappedException) + } + // retry with deleting query index if (monitor.deleteQueryIndexInEveryRun == true) { try { @@ -648,14 +691,17 @@ class DocLevelMonitorQueries(private val client: Client, private val clusterServ if (clusterState.routingTable.hasIndex(concreteIndexName)) { val indexMetadata = clusterState.metadata.index(concreteIndexName) if (indexMetadata.mapping()?.sourceAsMap?.get("properties") != null) { - val properties = ( - (indexMetadata.mapping()?.sourceAsMap?.get("properties")) - as MutableMap - ) + @Suppress("UNCHECKED_CAST") + val properties = deepCopyMap( + indexMetadata.mapping()?.sourceAsMap?.get("properties") as Map + ) // Node processor function is used to process leaves of index mappings tree // val leafNodeProcessor = fun(fieldName: String, _: String, props: MutableMap): Triple> { + if (props["type"] == "alias") { + return Triple(fieldName, fieldName, mutableMapOf()) + } return Triple(fieldName, fieldName, props) } // Traverse and update index mappings here while extracting flatten field paths @@ -663,6 +709,8 @@ class DocLevelMonitorQueries(private val client: Client, private val clusterServ traverseMappingsAndUpdate(properties, "", leafNodeProcessor, flattenPaths) flattenPaths.forEach { + // skip alias-type fields — they differ by design across indices and must not be flagged + if (it.value["type"] == "alias") return@forEach if (allFlattenPaths.containsKey(it.key) && allFlattenPaths[it.key]!! != it.value) { conflictingFields.add(it.key) } diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/util/DocLevelMonitorQueriesTests.kt b/alerting/src/test/kotlin/org/opensearch/alerting/util/DocLevelMonitorQueriesTests.kt new file mode 100644 index 000000000..2f43c2f91 --- /dev/null +++ b/alerting/src/test/kotlin/org/opensearch/alerting/util/DocLevelMonitorQueriesTests.kt @@ -0,0 +1,494 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.alerting.util + +import org.mockito.Mockito.mock +import org.opensearch.cluster.service.ClusterService +import org.opensearch.test.OpenSearchTestCase +import org.opensearch.transport.client.Client + +/** + * Unit tests for [DocLevelMonitorQueries]. + * + * Covers: + * - [DocLevelMonitorQueries.deepCopyMap] — pure companion-object helper. + * - [DocLevelMonitorQueries.traverseMappingsAndUpdate] — DFS traversal with + * emphasis on the alias-field bug fix (fields of type "alias" must be skipped + * so they are never included in the PUT-mapping request sent to the query index). + * - [DocLevelMonitorQueries.getAllConflictingFields] behaviour is exercised + * indirectly via traverseMappingsAndUpdate; direct ClusterState integration + * tests belong in the IT suite. + * + * Existing traverseMappingsAndUpdate tests live in [AlertingUtilsTests]; new + * tests here focus specifically on alias-type fields and complement rather than + * duplicate those. + */ +class DocLevelMonitorQueriesTests : OpenSearchTestCase() { + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private fun makeInstance(): DocLevelMonitorQueries = + DocLevelMonitorQueries(mock(Client::class.java), mock(ClusterService::class.java)) + + /** Identity leaf processor — returns every field unchanged. */ + private val identityLeaf = + fun(fieldName: String, _: String, props: MutableMap): + Triple> = + Triple(fieldName, fieldName, props) + + /** Alias-skipping leaf processor that mirrors the production bugfix logic. */ + private val aliasSkippingLeaf = + fun(fieldName: String, _: String, props: MutableMap): + Triple> { + if (props["type"] == "alias") { + return Triple(fieldName, fieldName, mutableMapOf()) + } + return Triple(fieldName, fieldName, props) + } + + // ------------------------------------------------------------------------- + // deepCopyMap — structural correctness + // ------------------------------------------------------------------------- + + fun `test deepCopyMap returns an equal but distinct map reference`() { + val original = mutableMapOf("key" to "value") + + val copy = DocLevelMonitorQueries.deepCopyMap(original) + + assertEquals(original, copy) + assertNotSame(original, copy) + } + + fun `test deepCopyMap deep copies nested maps so mutations are isolated`() { + val nested = mutableMapOf("inner" to "original") + val original = mutableMapOf("outer" to nested) + + val copy = DocLevelMonitorQueries.deepCopyMap(original) + + // Mutate the nested map in the copy. + @Suppress("UNCHECKED_CAST") + (copy["outer"] as MutableMap)["inner"] = "mutated" + + // Original nested map must be unaffected. + assertEquals("original", nested["inner"]) + } + + fun `test deepCopyMap copies nested map references independently`() { + val nested = mutableMapOf("inner" to "value") + val original = mutableMapOf("outer" to nested) + + val copy = DocLevelMonitorQueries.deepCopyMap(original) + + assertNotSame(nested, copy["outer"]) + } + + fun `test deepCopyMap shallow copies list values`() { + val list = mutableListOf("a", "b", "c") + val original = mutableMapOf("items" to list) + + val copy = DocLevelMonitorQueries.deepCopyMap(original) + + @Suppress("UNCHECKED_CAST") + val copiedList = copy["items"] as MutableList + copiedList.add("d") + + // The list in the copy is a distinct mutable list; original list unchanged. + assertEquals(3, list.size) + assertEquals(4, copiedList.size) + assertNotSame(list, copiedList) + } + + fun `test deepCopyMap preserves scalar values`() { + val original = mutableMapOf( + "strVal" to "hello", + "intVal" to 42, + "boolVal" to true, + "longVal" to 9_999_999_999L, + ) + + val copy = DocLevelMonitorQueries.deepCopyMap(original) + + assertEquals("hello", copy["strVal"]) + assertEquals(42, copy["intVal"]) + assertEquals(true, copy["boolVal"]) + assertEquals(9_999_999_999L, copy["longVal"]) + } + + fun `test deepCopyMap handles empty map`() { + val copy = DocLevelMonitorQueries.deepCopyMap(emptyMap()) + + assertTrue(copy.isEmpty()) + } + + fun `test deepCopyMap handles multiply-nested structure`() { + val original = mutableMapOf( + "level1" to mutableMapOf( + "level2" to mutableMapOf( + "level3" to "leaf", + ), + ), + ) + + val copy = DocLevelMonitorQueries.deepCopyMap(original) + + @Suppress("UNCHECKED_CAST") + val l1 = copy["level1"] as MutableMap + @Suppress("UNCHECKED_CAST") + val l2 = l1["level2"] as MutableMap + assertEquals("leaf", l2["level3"]) + + // Mutate deep copy — original must be unaffected. + l2["level3"] = "changed" + @Suppress("UNCHECKED_CAST") + val origL2 = (original["level1"] as Map)["level2"] as Map + assertEquals("leaf", origL2["level3"]) + } + + // ------------------------------------------------------------------------- + // traverseMappingsAndUpdate — alias-field bug fix (core of this PR) + // ------------------------------------------------------------------------- + + fun `test traverseMappingsAndUpdate skips alias-type field when leaf processor returns empty props`() { + val queries = makeInstance() + // Mapping that contains a single alias-type field. + val mappings = mutableMapOf( + "real_field_alias" to mutableMapOf( + "type" to "alias", + "path" to "some.other.field", + ), + ) + val flattenPaths = mutableMapOf>() + + queries.traverseMappingsAndUpdate(mappings, "", aliasSkippingLeaf, flattenPaths) + + // traverseMappingsAndUpdate ADDS every leaf to flattenPaths before calling the + // processor, so the alias field IS present in flattenPaths here. The production + // fix filters it out AFTER traversal (in the properties-copy loop inside + // indexDocLevelQueries). What we verify is that the processor returned an empty + // props map for the alias field, and that the node in `mappings` was updated to + // an empty map (the processor renaming result). + assertTrue( + "alias field should be present in flattenPaths (traversal is unconditional)", + flattenPaths.containsKey("real_field_alias"), + ) + // The critical invariant: after traversal, the entry in the parent map is an + // empty mutableMap — this is what the post-traversal filter in + // indexDocLevelQueries checks when deciding whether to skip a field. + val updatedEntry = mappings["real_field_alias"] + assertTrue( + "alias field entry must be empty after processing so the caller can filter it", + updatedEntry is Map<*, *> && (updatedEntry as Map<*, *>).isEmpty(), + ) + } + + fun `test traverseMappingsAndUpdate includes non-alias fields normally`() { + val queries = makeInstance() + val mappings = mutableMapOf( + "message" to mutableMapOf("type" to "text"), + "status_code" to mutableMapOf("type" to "keyword"), + ) + val flattenPaths = mutableMapOf>() + + queries.traverseMappingsAndUpdate(mappings, "", identityLeaf, flattenPaths) + + assertTrue("message should be in flattenPaths", flattenPaths.containsKey("message")) + assertTrue("status_code should be in flattenPaths", flattenPaths.containsKey("status_code")) + } + + fun `test traverseMappingsAndUpdate with mixed alias and regular fields only regular fields get non-empty props`() { + val queries = makeInstance() + // Two regular fields + one alias field. + val mappings = mutableMapOf( + "timestamp" to mutableMapOf("type" to "date"), + "log_level" to mutableMapOf("type" to "keyword"), + "ts_alias" to mutableMapOf( + "type" to "alias", + "path" to "timestamp", + ), + ) + val flattenPaths = mutableMapOf>() + + queries.traverseMappingsAndUpdate(mappings, "", aliasSkippingLeaf, flattenPaths) + + // All three leaves appear in flattenPaths because traversal is unconditional. + assertEquals(3, flattenPaths.size) + + // Regular fields retain their props. + assertEquals("date", flattenPaths["timestamp"]?.get("type")) + assertEquals("keyword", flattenPaths["log_level"]?.get("type")) + + // The alias entry in the parent `mappings` map was replaced with an empty map + // by the processor — this is what the post-traversal filter checks. + val aliasEntry = mappings["ts_alias"] + assertTrue( + "ts_alias entry must become empty so the caller can skip it in PUT mapping", + aliasEntry is Map<*, *> && (aliasEntry as Map<*, *>).isEmpty(), + ) + + // Regular fields still have their original type in the parent map. + val timestampEntry = mappings["timestamp"] as Map<*, *> + assertEquals("date", timestampEntry["type"]) + } + + fun `test traverseMappingsAndUpdate with all alias fields results in all empty entries`() { + val queries = makeInstance() + val mappings = mutableMapOf( + "alias_a" to mutableMapOf("type" to "alias", "path" to "field_a"), + "alias_b" to mutableMapOf("type" to "alias", "path" to "field_b"), + ) + val flattenPaths = mutableMapOf>() + + queries.traverseMappingsAndUpdate(mappings, "", aliasSkippingLeaf, flattenPaths) + + // Both are in flattenPaths (traversal doesn't filter). + assertEquals(2, flattenPaths.size) + + // Both entries in the working map are now empty. + assertTrue((mappings["alias_a"] as Map<*, *>).isEmpty()) + assertTrue((mappings["alias_b"] as Map<*, *>).isEmpty()) + } + + fun `test traverseMappingsAndUpdate with nested object containing alias field`() { + val queries = makeInstance() + // Nested object with a regular child and an alias child. + val mappings = mutableMapOf( + "event" to mutableMapOf( + "properties" to mutableMapOf( + "name" to mutableMapOf("type" to "keyword"), + "name_alias" to mutableMapOf( + "type" to "alias", + "path" to "event.name", + ), + ), + ), + ) + val flattenPaths = mutableMapOf>() + + queries.traverseMappingsAndUpdate(mappings, "", aliasSkippingLeaf, flattenPaths) + + assertTrue("event.name should be in flattenPaths", flattenPaths.containsKey("event.name")) + assertTrue("event.name_alias should be in flattenPaths", flattenPaths.containsKey("event.name_alias")) + + // Inside the nested properties, the alias entry must be empty. + @Suppress("UNCHECKED_CAST") + val eventProps = ((mappings["event"] as Map)["properties"] as Map) + assertTrue((eventProps["name_alias"] as Map<*, *>).isEmpty()) + // Regular child is untouched. + assertEquals("keyword", (eventProps["name"] as Map<*, *>)["type"]) + } + + fun `test traverseMappingsAndUpdate with empty mappings produces empty flattenPaths`() { + val queries = makeInstance() + val mappings = mutableMapOf() + val flattenPaths = mutableMapOf>() + + queries.traverseMappingsAndUpdate(mappings, "", identityLeaf, flattenPaths) + + assertTrue(flattenPaths.isEmpty()) + } + + // ------------------------------------------------------------------------- + // Post-traversal filter logic (unit-testing the filtering predicate + // independently of the full indexDocLevelQueries coroutine stack) + // ------------------------------------------------------------------------- + + /** + * The production fix in indexDocLevelQueries filters the `properties` map with: + * + * (it.value as Map)["type"] == "alias" || (it.value as Map).isEmpty() + * + * We verify that predicate directly so we know it catches both the + * alias-type case and the empty-map case produced by the processor. + */ + fun `test post-traversal filter skips entries where type is alias`() { + val entries: Map = mapOf( + "regular_field" to mapOf("type" to "keyword"), + "alias_field" to mapOf("type" to "alias", "path" to "regular_field"), + ) + + val kept = entries.filter { (_, v) -> + val m = v as Map<*, *> + m["type"] != "alias" && m.isNotEmpty() + } + + assertEquals(1, kept.size) + assertTrue(kept.containsKey("regular_field")) + assertFalse(kept.containsKey("alias_field")) + } + + fun `test post-traversal filter skips entries with empty map`() { + val entries: Map = mapOf( + "regular_field" to mapOf("type" to "date"), + "alias_field" to emptyMap(), + ) + + val kept = entries.filter { (_, v) -> + val m = v as Map<*, *> + m["type"] != "alias" && m.isNotEmpty() + } + + assertEquals(1, kept.size) + assertTrue(kept.containsKey("regular_field")) + } + + fun `test post-traversal filter retains all entries when no alias fields present`() { + val entries: Map = mapOf( + "field_a" to mapOf("type" to "keyword"), + "field_b" to mapOf("type" to "text"), + "field_c" to mapOf("type" to "long"), + ) + + val kept = entries.filter { (_, v) -> + val m = v as Map<*, *> + m["type"] != "alias" && m.isNotEmpty() + } + + assertEquals(3, kept.size) + } + + // ------------------------------------------------------------------------- + // allFlattenPaths post-traversal filter (H1 fix verification) + // + // traverseMappingsAndUpdate adds every leaf to flattenPaths BEFORE calling + // the processor, so alias paths land in flattenPaths with their original props. + // The fix in indexDocLevelQueries filters flattenPaths entries whose props + // have type=="alias" before adding to allFlattenPaths, preventing alias field + // names from flowing into query rewrites for non-existent fields. + // ------------------------------------------------------------------------- + + fun `test allFlattenPaths only contains non-alias fields after post-traversal filter`() { + val queries = makeInstance() + val mappings = mutableMapOf( + "timestamp" to mutableMapOf("type" to "date"), + "ts_alias" to mutableMapOf("type" to "alias", "path" to "timestamp"), + ) + val flattenPaths = mutableMapOf>() + + queries.traverseMappingsAndUpdate(mappings, "", aliasSkippingLeaf, flattenPaths) + + // Raw flattenPaths contains both because traversal is unconditional. + assertEquals(2, flattenPaths.size) + assertTrue(flattenPaths.containsKey("ts_alias")) + + // Simulated production filter: exclude entries whose props are empty + // (alias-skipping processor sets them to mutableMapOf()) or whose type is "alias". + // This mirrors what SHOULD be applied when building allFlattenPaths. + val filteredPaths = flattenPaths.filter { (_, props) -> + props.isNotEmpty() && props["type"] != "alias" + } + + assertEquals( + "filtered allFlattenPaths must contain only non-alias fields", + 1, + filteredPaths.size, + ) + assertTrue(filteredPaths.containsKey("timestamp")) + assertFalse( + "alias field must be excluded from filteredPaths to avoid query rewrites for non-existent field names", + filteredPaths.containsKey("ts_alias"), + ) + } + + // ------------------------------------------------------------------------- + // getAllConflictingFields alias guard (H2 fix verification) + // + // getAllConflictingFields now skips alias-type fields via a type=="alias" guard + // in the flattenPaths forEach loop. Two indices with an alias field at the + // same path but pointing to different targets will no longer produce a false + // conflict because the guard skips them before the equality comparison. + // ------------------------------------------------------------------------- + + fun `test alias-skipping processor excludes alias fields from conflict detection (getAllConflictingFields fix)`() { + val queries = makeInstance() + + // Index A: alias field pointing to "event.start" + val mappingsA = mutableMapOf( + "event_start" to mutableMapOf("type" to "alias", "path" to "event.start"), + "message" to mutableMapOf("type" to "text"), + ) + // Index B: same alias field name but pointing to "event.begin" — different path + val mappingsB = mutableMapOf( + "event_start" to mutableMapOf("type" to "alias", "path" to "event.begin"), + "message" to mutableMapOf("type" to "text"), + ) + + val flattenPathsA = mutableMapOf>() + val flattenPathsB = mutableMapOf>() + + // Alias-skipping processor — mirrors the FIXED getAllConflictingFields behaviour. + queries.traverseMappingsAndUpdate(mappingsA, "", aliasSkippingLeaf, flattenPathsA) + queries.traverseMappingsAndUpdate(mappingsB, "", aliasSkippingLeaf, flattenPathsB) + + // flattenPaths stores original nodeProps (captured before the processor runs), + // so alias fields appear with their original type=alias props — not empty. + assertTrue("Index A flattenPaths must include event_start", flattenPathsA.containsKey("event_start")) + assertTrue("Index B flattenPaths must include event_start", flattenPathsB.containsKey("event_start")) + assertEquals("alias", flattenPathsA["event_start"]!!["type"]) + assertEquals("alias", flattenPathsB["event_start"]!!["type"]) + + // Simulate the FIXED conflict-detection logic from getAllConflictingFields: + // seed allFlattenPaths with index A, then check index B against it, + // skipping entries whose type is "alias". + val allFlattenPaths = mutableMapOf>() + val conflictingFields = mutableSetOf() + flattenPathsA.forEach { (k, v) -> + if (v["type"] == "alias") return@forEach + allFlattenPaths[k] = v + } + flattenPathsB.forEach { (k, v) -> + if (v["type"] == "alias") return@forEach + if (allFlattenPaths.containsKey(k) && allFlattenPaths[k]!! != v) { + conflictingFields.add(k) + } + allFlattenPaths.putIfAbsent(k, v) + } + + // "message" is identical in both indices — must NOT be flagged. + assertFalse("message must not be a conflicting field", conflictingFields.contains("message")) + + // "event_start" is an alias field — the type=="alias" guard skips it, + // so it must NOT be flagged as conflicting. + assertFalse( + "Fixed: alias field with differing paths across indices must NOT be flagged as conflicting", + conflictingFields.contains("event_start"), + ) + } + + // ------------------------------------------------------------------------- + // deepCopyMap — list-of-maps recursion (Gap 3) + // + // The original implementation used toMutableList() which is a shallow copy: + // map elements inside the list were shared between original and copy. + // The fix recurses into map elements inside lists. + // ------------------------------------------------------------------------- + + fun `test deepCopyMap deep copies map elements inside lists`() { + val innerMap = mutableMapOf("nested_key" to "original_value") + val original = mutableMapOf( + "items" to mutableListOf(innerMap, "plain_string"), + ) + + val copy = DocLevelMonitorQueries.deepCopyMap(original) + + @Suppress("UNCHECKED_CAST") + val copiedList = copy["items"] as MutableList + @Suppress("UNCHECKED_CAST") + val copiedInnerMap = copiedList[0] as MutableMap + + // Mutate the map element inside the copied list. + copiedInnerMap["nested_key"] = "mutated_value" + + // The original inner map must be unaffected — deep copy isolated it. + assertEquals( + "Map element inside list must be deep-copied so mutations do not affect the original", + "original_value", + innerMap["nested_key"], + ) + assertNotSame(innerMap, copiedInnerMap) + } +}