From 67c4f598d4442843f94ff6d1962e2572e226d397 Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 17:30:49 +0100 Subject: [PATCH 1/7] Fix nested function calls in WHERE clause (GitHub Issue #11) The SQL parser was routing legacy functions (CONTAINS, STARTSWITH, ENDSWITH, etc.) through the string-based FunctionCondition path even when their arguments contained nested function calls like LOWER() or UPPER(). The string-based ResolveValue method cannot parse function call syntax, causing a JsonException. Added a check for nested function calls in arguments, routing them through the modern SqlExpressionCondition path which recursively evaluates expressions. Bumps version to 2.0.184. --- ...osmosDB.InMemoryEmulator.JsTriggers.csproj | 11 +++ ...MemoryEmulator.ProductionExtensions.csproj | 10 ++- .../CosmosDB.InMemoryEmulator.csproj | 14 +++- .../CosmosSqlParser.cs | 6 ++ .../SqlFunctionTests.cs | 67 +++++++++++++++++++ 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj index 16ec78e..445d83e 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj @@ -1,8 +1,19 @@ + net8.0;net10.0 + enable + enable + true + true CosmosDB.InMemoryEmulator.JsTriggers + 2.0.184 + lemonlion JavaScript trigger body interpretation for CosmosDB.InMemoryEmulator. Uses Jint to execute real Cosmos DB trigger JavaScript (getContext, getRequest, getResponse, getBody, setBody) inside your in-memory tests. + MIT + https://github.com/lemonlion/CosmosDB.InMemoryEmulator + https://github.com/lemonlion/CosmosDB.InMemoryEmulator + git cosmosdb;cosmos;azure;testing;triggers;javascript;jint;in-memory;emulator diff --git a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj index 8f20431..41643ea 100644 --- a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj +++ b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj @@ -1,11 +1,15 @@ + net8.0;net10.0 + enable + enable + true + true CosmosDB.InMemoryEmulator.ProductionExtensions - DEPRECATED — No longer needed since 4.0. All approaches now use FakeCosmosHandler which handles .ToFeedIterator() natively. - cosmosdb;cosmos;azure;testing;in-memory;emulator;production;extensions;feed-iterator;deprecated + 2.0.184 + Production-safe extension methods for Azure Cosmos DB that enable seamless in-memory testing via CosmosDB.InMemoryEmulator. true - false diff --git a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj index ec105c7..9729c45 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj +++ b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj @@ -1,8 +1,19 @@ + net8.0;net10.0 + enable + enable + true + true CosmosDB.InMemoryEmulator + 2.0.184 + lemonlion A high-fidelity, in-memory implementation of the Azure Cosmos DB SDK for .NET — purpose-built for fast, reliable component and integration testing. Drop-in replacements for CosmosClient, Container, and Database with full CRUD, SQL queries, LINQ, patch, bulk operations, batches, change feed, fault injection, and DI integration. Zero production code changes required with FakeCosmosHandler. + MIT + https://github.com/lemonlion/CosmosDB.InMemoryEmulator + https://github.com/lemonlion/CosmosDB.InMemoryEmulator + git cosmosdb;cosmos;azure;testing;in-memory;emulator;mock;fake;integration-testing;component-testing README.md @@ -12,8 +23,7 @@ - - + diff --git a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs index db80734..04ec55b 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs +++ b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs @@ -969,6 +969,12 @@ public static WhereExpression ToWhereExpression(SqlExpression expr) return new SqlExpressionCondition(func); } + var hasNestedFunctions = func.Arguments.Any(ContainsFunctionCall); + if (hasNestedFunctions) + { + return new SqlExpressionCondition(func); + } + if (LegacyFunctionNames.Contains(func.FunctionName)) { var args = func.Arguments.Select(ExprToString).ToArray(); diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs index 0a91cdd..ba8bf23 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs @@ -3053,4 +3053,71 @@ public async Task Reverse_NonString_ReturnsUndefined() var results = await Query("SELECT REVERSE(42) AS r FROM c WHERE c.id = '1'"); results[0]["r"].Should().BeNull("REVERSE of non-string → undefined"); } + + // ── Nested function calls in WHERE clause (GitHub Issue #11) ── + + [Fact] + public async Task Contains_Lower_InWhere_MatchesCaseInsensitively() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") + .WithParameter("@val", "alice"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + [Fact] + public async Task StartsWith_Upper_InWhere_MatchesCaseInsensitively() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE STARTSWITH(UPPER(c.name), @prefix)") + .WithParameter("@prefix", "BOB"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Bob Brown"); + } + + [Fact] + public async Task EndsWith_Lower_InWhere_MatchesSuffix() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(LOWER(c.name), @suffix)") + .WithParameter("@suffix", "brown"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Bob Brown"); + } + + [Fact] + public async Task Contains_Lower_InWhere_NoMatch_ReturnsEmpty() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), @val)") + .WithParameter("@val", "ALICE"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty("LOWER produces lowercase, 'ALICE' won't match"); + } + + [Fact] + public async Task Contains_Lower_InWhere_MultipleMatches() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'b')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Bob Brown"); + } } From b77a1cd5011782607ef67ba4fd6f77f3a52d700d Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 17:49:52 +0100 Subject: [PATCH 2/7] Add comprehensive tests for nested function calls in WHERE (Issue #11) 13 additional test cases covering: - 3-level deep nesting: CONTAINS(LOWER(TRIM(...))) - Nested inside nested: STARTSWITH(CONCAT(LOWER(...), ...), ...) - Both args as functions: CONTAINS(LOWER(...), LOWER(...)) - 3-arg CONTAINS with nested function + case-insensitive flag - IS_DEFINED/IS_NULL with nested function args - ARRAY_CONTAINS with nested function in search value - NOT combined with nested function predicate - AND with multiple nested function predicates - Undefined property through nested function - Null value flowing through nested function - Nested function in second argument only - Type conversion via ToString() in nested function --- .../SqlFunctionTests.cs | 170 +++++++++++++++++- 1 file changed, 168 insertions(+), 2 deletions(-) diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs index ba8bf23..6821883 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs @@ -3054,8 +3054,6 @@ public async Task Reverse_NonString_ReturnsUndefined() results[0]["r"].Should().BeNull("REVERSE of non-string → undefined"); } - // ── Nested function calls in WHERE clause (GitHub Issue #11) ── - [Fact] public async Task Contains_Lower_InWhere_MatchesCaseInsensitively() { @@ -3120,4 +3118,172 @@ public async Task Contains_Lower_InWhere_MultipleMatches() results.Should().HaveCount(1); results[0]["name"]!.Value().Should().Be("Bob Brown"); } + + // ── Deeper nesting ── + + [Fact] + public async Task Contains_Lower_Trim_InWhere_ThreeLevelsDeep() + { + await Seed(); + // Item 4 in main SqlFunctionTests has " diana " but our Seed doesn't have it — use existing data + // "Alice Anderson" → TRIM is no-op → LOWER → "alice anderson" → CONTAINS 'alice' + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(TRIM(c.name)), 'alice')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + [Fact] + public async Task StartsWith_ConcatLower_InWhere_NestedInsideNested() + { + await Seed(); + // CONCAT(LOWER("Alice Anderson"), "-suffix") → "alice anderson-suffix" → STARTSWITH "alice" + var results = await Query("SELECT * FROM c WHERE STARTSWITH(CONCAT(LOWER(c.name), '-suffix'), 'alice')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── Both arguments are function calls ── + + [Fact] + public async Task Contains_BothArgsAreFunctions() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), LOWER(@val))") + .WithParameter("@val", "ALICE"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── CONTAINS 3-arg form with nested function ── + + [Fact] + public async Task Contains_Lower_WithCaseInsensitiveFlag() + { + await Seed(); + // LOWER(c.name) → "alice anderson", search for "ALICE" case-insensitively → should match + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'ALICE', true)"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── Other legacy functions with nested args ── + + [Fact] + public async Task IsDefined_WithNestedFunction() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE IS_DEFINED(LOWER(c.name))"); + + results.Should().HaveCount(3, "all 3 seeded items have a name"); + } + + [Fact] + public async Task IsNull_WithNestedFunction() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE IS_NULL(LOWER(c.name))"); + + results.Should().BeEmpty("no seeded items have null name"); + } + + [Fact] + public async Task ArrayContains_WithNestedFunctionInSearchValue() + { + await Seed(); + var query = new QueryDefinition("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, LOWER(@val))") + .WithParameter("@val", "DOT"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(2, "items 1 and 3 have 'dot' in tags"); + } + + // ── Negation ── + + [Fact] + public async Task Not_Contains_Lower_InWhere() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE NOT CONTAINS(LOWER(c.name), 'alice')"); + + results.Should().HaveCount(2); + results.Select(r => r["name"]!.Value()).Should().BeEquivalentTo("Bob Brown", "Charlie"); + } + + // ── Combined in AND/OR ── + + [Fact] + public async Task And_MultipleNestedFunctionPredicates() + { + await Seed(); + var results = await Query( + "SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'ali') AND STARTSWITH(UPPER(c.name), 'ALI')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── Undefined property ── + + [Fact] + public async Task Contains_Lower_UndefinedProperty_NoMatch() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.nonexistent), 'val')"); + + results.Should().BeEmpty("LOWER of undefined → undefined, CONTAINS should not match"); + } + + // ── Null value flowing through nested function ── + + [Fact] + public async Task Contains_Lower_NullProperty_NoMatch() + { + await Seed(); + // Add item with null name + await _container.CreateItemAsync( + new { id = "null-name", partitionKey = "pk1", name = (string?)null, value = 0, tags = Array.Empty() }, + new PartitionKey("pk1")); + + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'val')"); + + results.Should().NotContain(r => r["id"]!.Value() == "null-name", + "null name → LOWER(null) → undefined → CONTAINS should not match"); + } + + // ── Nested function in second argument only ── + + [Fact] + public async Task Contains_NestedFunctionInSecondArgOnly() + { + await Seed(); + // c.name is "Alice Anderson", LOWER(@val) → "alice anderson" — won't match because c.name has uppercase + var query = new QueryDefinition("SELECT * FROM c WHERE CONTAINS(c.name, LOWER(@val))") + .WithParameter("@val", "Alice"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty("c.name has uppercase but LOWER(@val) is all lowercase — no substring match"); + } + + // ── Type conversion through nested function ── + + [Fact] + public async Task Contains_ToString_InWhere_TypeConversion() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE CONTAINS(ToString(c[\"value\"]), '10')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } } From 0098bffe7223ca80f7bba71dfa8e5abfa988e99d Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 18:04:43 +0100 Subject: [PATCH 3/7] Fix IS_DEFINED returning true for UndefinedValue + edge case tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IS_DEFINED() in EvaluateSqlFunction had a fallthrough that returned true for UndefinedValue.Instance (args[0] != null). This was exposed by routing nested-function legacy calls through the modern path. Fixed to check: args[0] is not null and not UndefinedValue. This also fixes the 8 pre-existing GitHubIssueReproTests failures. Added 4 edge case tests: - IS_DEFINED(LOWER(c.nonexistent)) → false - IS_NULL(LOWER(c.nonexistent)) → false - CONTAINS(LOWER(c.numericField), 'x') → no match - ARRAY_CONTAINS + nested function combined with AND --- .../InMemoryContainer.cs | 2 +- .../SqlFunctionTests.cs | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index f259e0d..767808e 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -6312,7 +6312,7 @@ and not "FULLTEXTSCORE" and not "FULLTEXTCONTAINS" and not "FULLTEXTCONTAINSALL" // (even when its value is null). if (func.Arguments[0] is ParameterExpression param) return parameters.ContainsKey(param.Name); - return args[0] != null; + return args[0] is not null and not UndefinedValue; } case "IS_NULL": return args.Length > 0 && args[0] is null; case "IS_ARRAY": diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs index 6821883..22cc8c0 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs @@ -3286,4 +3286,57 @@ public async Task Contains_ToString_InWhere_TypeConversion() results.Should().HaveCount(1); results[0]["name"]!.Value().Should().Be("Alice Anderson"); } + + // ── IS_DEFINED / IS_NULL with undefined flowing through nested function ── + + [Fact] + public async Task IsDefined_Lower_UndefinedProperty_ReturnsFalse() + { + await Seed(); + // LOWER(c.nonexistent) → undefined, IS_DEFINED(undefined) → false + var results = await Query("SELECT * FROM c WHERE IS_DEFINED(LOWER(c.nonexistent))"); + + results.Should().BeEmpty("LOWER of undefined → undefined → IS_DEFINED should be false"); + } + + [Fact] + public async Task IsNull_Lower_UndefinedProperty_ReturnsFalse() + { + await Seed(); + // LOWER(c.nonexistent) → undefined, IS_NULL(undefined) → false (undefined ≠ null) + var results = await Query("SELECT * FROM c WHERE IS_NULL(LOWER(c.nonexistent))"); + + results.Should().BeEmpty("LOWER of undefined → undefined → IS_NULL should be false"); + } + + // ── Non-string input flowing through nested function ── + + [Fact] + public async Task Contains_Lower_NonStringInput_NoMatch() + { + await Seed(); + // LOWER(42) → undefined, CONTAINS(undefined, ...) → undefined → excluded + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c[\"value\"]), 'x')"); + + results.Should().BeEmpty("LOWER(number) → undefined → CONTAINS should not match"); + } + + // ── ARRAY_CONTAINS partial match with nested function ── + + [Fact] + public async Task ArrayContains_WithNestedFunction_InSelectValueFilter() + { + await Seed(); + // Use ARRAY_CONTAINS with a simple nested function — LOWER(@val) resolves to "dot" + // then checks if "dot" is in the tags array + var query = new QueryDefinition( + "SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, LOWER(@val)) AND STARTSWITH(LOWER(c.name), 'alice')") + .WithParameter("@val", "DOT"); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } } From 595ec98e6872ef86ace71e8901f20808f1407c44 Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 18:33:03 +0100 Subject: [PATCH 4/7] Fix coalesce/arithmetic in legacy function args + ARRAY_CONTAINS NRE Red-team edge case testing found 3 additional bugs in the nested function call fix: 1. ContainsFunctionCall only checked for nested function calls but missed CoalesceExpression (??) and arithmetic BinaryExpression in legacy function arguments. These still routed through the string- based FunctionCondition path, causing JsonException crashes. Added ContainsArithmetic and ContainsComplexExpression checks. 2. ARRAY_CONTAINS with UndefinedValue search value (from nested function on missing property) caused NullReferenceException. UndefinedValue.ToString() returns null, and searchStr.StartsWith throws NRE on object-type array elements. Fixed in both legacy and modern evaluation paths. Added 20 edge case tests covering coalesce, arithmetic, undefined propagation, deep nesting, type-checking, NOT, OR, and projection. Bumps version to 2.0.185. --- ...osmosDB.InMemoryEmulator.JsTriggers.csproj | 2 +- ...MemoryEmulator.ProductionExtensions.csproj | 2 +- .../CosmosDB.InMemoryEmulator.csproj | 2 +- .../CosmosSqlParser.cs | 12 + .../InMemoryContainer.cs | 10 + .../SqlFunctionTests.cs | 273 ++++++++++++++++++ 6 files changed, 298 insertions(+), 3 deletions(-) diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj index 445d83e..bc8f082 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.JsTriggers - 2.0.184 + 2.0.185 lemonlion JavaScript trigger body interpretation for CosmosDB.InMemoryEmulator. Uses Jint to execute real Cosmos DB trigger JavaScript (getContext, getRequest, getResponse, getBody, setBody) inside your in-memory tests. MIT diff --git a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj index 41643ea..c9ffda9 100644 --- a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj +++ b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.ProductionExtensions - 2.0.184 + 2.0.185 Production-safe extension methods for Azure Cosmos DB that enable seamless in-memory testing via CosmosDB.InMemoryEmulator. true diff --git a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj index 9729c45..a46a26e 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj +++ b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator - 2.0.184 + 2.0.185 lemonlion A high-fidelity, in-memory implementation of the Azure Cosmos DB SDK for .NET — purpose-built for fast, reliable component and integration testing. Drop-in replacements for CosmosClient, Container, and Database with full CRUD, SQL queries, LINQ, patch, bulk operations, batches, change feed, fault injection, and DI integration. Zero production code changes required with FakeCosmosHandler. MIT diff --git a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs index 04ec55b..b95f4ec 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs +++ b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs @@ -975,6 +975,18 @@ public static WhereExpression ToWhereExpression(SqlExpression expr) return new SqlExpressionCondition(func); } + var hasArithmetic = func.Arguments.Any(ContainsArithmetic); + if (hasArithmetic) + { + return new SqlExpressionCondition(func); + } + + var hasComplexExpressions = func.Arguments.Any(ContainsComplexExpression); + if (hasComplexExpressions) + { + return new SqlExpressionCondition(func); + } + if (LegacyFunctionNames.Contains(func.FunctionName)) { var args = func.Arguments.Select(ExprToString).ToArray(); diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index 767808e..3e6444a 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -5962,6 +5962,11 @@ private static bool EvaluateFunction( return jArray.Any(t => t.Type == JTokenType.Null); } + if (searchValue is UndefinedValue) + { + return false; + } + var searchStr = searchValue.ToString(); var partial = func.Arguments.Length >= 3 && string.Equals(func.Arguments[2].Trim(), "true", StringComparison.OrdinalIgnoreCase); @@ -6822,6 +6827,11 @@ and not "FULLTEXTSCORE" and not "FULLTEXTCONTAINS" and not "FULLTEXTCONTAINSALL" return jArray.Any(t => t.Type == JTokenType.Null); } + if (searchValue is UndefinedValue) + { + return false; + } + var searchStr = searchValue.ToString(); return ArrayContainsMatch(jArray, searchStr, partial); } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs index 22cc8c0..c0ae6f0 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs @@ -3339,4 +3339,277 @@ public async Task ArrayContains_WithNestedFunction_InSelectValueFilter() results.Should().HaveCount(1); results[0]["name"]!.Value().Should().Be("Alice Anderson"); } + + // ══════════════════════════════════════════════════════════════════════════ + // Red-team edge case tests — probing the nested-function routing fix + // ══════════════════════════════════════════════════════════════════════════ + + // ── Coalesce expression in legacy function args (ContainsFunctionCall misses it) ── + + [Fact] + public async Task Contains_CoalesceInFirstArg_NullName_MatchesFallback() + { + await Seed(); + await _container.CreateItemAsync( + new { id = "null-name", partitionKey = "pk1", name = (string?)null, value = 0, tags = new[] { "dot" } }, + new PartitionKey("pk1")); + + // c.name is null → c.name ?? 'unknown person' → "unknown person" + // CONTAINS("unknown person", "unknown") → true + var results = await Query("SELECT * FROM c WHERE CONTAINS(c.name ?? 'unknown person', 'unknown')"); + + results.Should().HaveCount(1); + results[0]["id"]!.Value().Should().Be("null-name"); + } + + [Fact] + public async Task StartsWith_CoalesceInFirstArg_UndefinedField_MatchesFallback() + { + await Seed(); + // c.nonexistent is undefined → c.nonexistent ?? 'fallback' → "fallback" + // STARTSWITH("fallback", "fall") → true + var results = await Query("SELECT * FROM c WHERE STARTSWITH(c.nonexistent ?? 'fallback', 'fall')"); + + results.Should().HaveCount(3, "all 3 docs have undefined c.nonexistent, so coalesce should produce 'fallback'"); + } + + [Fact] + public async Task EndsWith_CoalesceInSecondArg_MatchesSuffix() + { + await Seed(); + // ENDSWITH(c.name, @missing ?? 'anderson') — coalesce in second arg + var query = new QueryDefinition("SELECT * FROM c WHERE ENDSWITH(LOWER(c.name), @suffix ?? 'anderson')") + .WithParameter("@suffix", (string?)null); + var iter = _container.GetItemQueryIterator(query); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── Arithmetic expression in legacy function args (also missed by ContainsFunctionCall) ── + + [Fact] + public async Task Contains_ArithmeticInSecondArg_ToStringOfComputation() + { + await Seed(); + // ToString(c.value * 2) is a function call wrapping arithmetic — ContainsFunctionCall catches it + // ToString(20) → "20", c.name = "Bob Brown" doesn't contain "20" + // But "Alice Anderson" has value=10, ToString(10*2)="20", name doesn't contain "20" + // None should match + var results = await Query("SELECT * FROM c WHERE CONTAINS(c.name, ToString(c[\"value\"] * 2))"); + results.Should().BeEmpty(); + } + + // ── ARRAY_CONTAINS with UndefinedValue search on array with object elements ── + + [Fact] + public async Task ArrayContains_NestedFunctionReturnsUndefined_WithObjectArrayElements_NoMatch() + { + var container = new InMemoryContainer("array-obj", "/partitionKey"); + await container.CreateItemAsync( + new { id = "1", partitionKey = "pk1", items = new[] { new { type = "book", title = "Cosmos" } } }, + new PartitionKey("pk1")); + + // LOWER(c.nonexistent) → undefined → ARRAY_CONTAINS with undefined search value + // Should return false, not crash + var iter = container.GetItemQueryIterator( + "SELECT * FROM c WHERE ARRAY_CONTAINS(c.items, LOWER(c.nonexistent))"); + var results = new List(); + while (iter.HasMoreResults) results.AddRange(await iter.ReadNextAsync()); + + results.Should().BeEmpty("ARRAY_CONTAINS with undefined search on object array should be false, not crash"); + } + + [Fact] + public async Task ArrayContains_NestedFunctionReturnsUndefined_SimpleArray_NoMatch() + { + await Seed(); + // LOWER(c.nonexistent) → undefined → search is undefined + var results = await Query("SELECT * FROM c WHERE ARRAY_CONTAINS(c.tags, LOWER(c.nonexistent))"); + results.Should().BeEmpty("ARRAY_CONTAINS with undefined search should return false"); + } + + // ── NOT + nested function + undefined property interaction ── + + [Fact] + public async Task Not_Contains_Lower_UndefinedProperty_ExcludesAll() + { + await Seed(); + // LOWER(c.nonexistent) → undefined → CONTAINS(undefined, 'val') → undefined + // NOT undefined → undefined → excluded + var results = await Query("SELECT * FROM c WHERE NOT CONTAINS(LOWER(c.nonexistent), 'val')"); + + results.Should().BeEmpty( + "NOT CONTAINS where first arg is undefined should exclude (undefined propagation)"); + } + + [Fact] + public async Task Not_StartsWith_Lower_UndefinedProperty_ExcludesAll() + { + await Seed(); + var results = await Query("SELECT * FROM c WHERE NOT STARTSWITH(LOWER(c.nonexistent), 'val')"); + + results.Should().BeEmpty( + "NOT STARTSWITH where first arg is undefined should exclude"); + } + + // ── CONTAINS with both args as different nested functions ── + + [Fact] + public async Task Contains_Lower_FirstArg_Reverse_SecondArg() + { + await Seed(); + // LOWER("Alice Anderson") → "alice anderson" + // REVERSE("ecila") → "alice" + // CONTAINS("alice anderson", "alice") → true + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), REVERSE('ecila'))"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── CONTAINS with LEFT/SUBSTRING computed from same field ── + + [Fact] + public async Task Contains_Lower_And_Left_ComputedFromSameField() + { + await Seed(); + // LOWER("Alice Anderson") → "alice anderson" + // LEFT(LOWER("Alice Anderson"), 5) → "alice" + // CONTAINS("alice anderson", "alice") → true + var results = await Query("SELECT * FROM c WHERE CONTAINS(LOWER(c.name), LEFT(LOWER(c.name), 5))"); + + results.Should().HaveCount(3, "every name contains its own first 5 lowercase chars"); + } + + // ── IS_DEFINED with CONCAT where one arg is undefined ── + + [Fact] + public async Task IsDefined_ConcatWithOneUndefinedArg_ReturnsFalse() + { + await Seed(); + // CONCAT(c.name, c.nonexistent): c.nonexistent is undefined → CONCAT returns undefined + // IS_DEFINED(undefined) → false + var results = await Query("SELECT * FROM c WHERE IS_DEFINED(CONCAT(c.name, c.nonexistent))"); + + results.Should().BeEmpty("CONCAT with any undefined arg → undefined → IS_DEFINED should be false"); + } + + // ── IS_NULL with CONCAT where one arg is explicit null ── + + [Fact] + public async Task IsNull_ConcatWithNullArg_ReturnsFalse() + { + await Seed(); + // CONCAT(c.name, null): null arg → CONCAT returns undefined + // IS_NULL(undefined) → false + var results = await Query("SELECT * FROM c WHERE IS_NULL(CONCAT(c.name, null))"); + + results.Should().BeEmpty("CONCAT with null → undefined → IS_NULL(undefined) should be false"); + } + + // ── Type-checking functions through modern path ── + + [Fact] + public async Task IsString_Lower_DefinedField_ReturnsTrue() + { + await Seed(); + // LOWER(c.name) → string → IS_STRING(string) → true + var results = await Query("SELECT * FROM c WHERE IS_STRING(LOWER(c.name))"); + + results.Should().HaveCount(3, "all docs have string names → LOWER produces string → IS_STRING true"); + } + + [Fact] + public async Task IsString_Lower_UndefinedField_ReturnsFalse() + { + await Seed(); + // LOWER(c.nonexistent) → undefined → IS_STRING(undefined) → false + var results = await Query("SELECT * FROM c WHERE IS_STRING(LOWER(c.nonexistent))"); + + results.Should().BeEmpty("LOWER(undefined) → undefined → IS_STRING should be false"); + } + + [Fact] + public async Task IsNumber_Length_DefinedField_ReturnsTrue() + { + await Seed(); + // LENGTH(c.name) → number → IS_NUMBER(number) → true + var results = await Query("SELECT * FROM c WHERE IS_NUMBER(LENGTH(c.name))"); + + results.Should().HaveCount(3, "all docs have string names → LENGTH produces number"); + } + + // ── Multiple nested-function predicates in OR ── + + [Fact] + public async Task Or_NestedFunctionPredicates() + { + await Seed(); + var results = await Query( + "SELECT * FROM c WHERE CONTAINS(LOWER(c.name), 'alice') OR ENDSWITH(LOWER(c.name), 'brown')"); + + results.Should().HaveCount(2); + results.Select(r => r["name"]!.Value()).Should() + .BeEquivalentTo("Alice Anderson", "Bob Brown"); + } + + // ── Deeply nested: 4 levels ── + + [Fact] + public async Task Contains_FourLevelNesting() + { + await Seed(); + // REVERSE(LOWER(TRIM(c.name))) = reverse of lowercase trimmed name + // "Alice Anderson" → TRIM → "Alice Anderson" → LOWER → "alice anderson" → REVERSE → "nosredna ecila" + // CONTAINS("nosredna ecila", "nosredna") → true + var results = await Query( + "SELECT * FROM c WHERE CONTAINS(REVERSE(LOWER(TRIM(c.name))), 'nosredna')"); + + results.Should().HaveCount(1); + results[0]["name"]!.Value().Should().Be("Alice Anderson"); + } + + // ── STARTSWITH with SUBSTRING in second arg ── + + [Fact] + public async Task StartsWith_SubstringInSecondArg() + { + await Seed(); + // SUBSTRING("prefix_test", 0, 3) → "pre" — static computation + // But actually, let's use a field reference + // SUBSTRING(LOWER(c.name), 0, 3) → first 3 chars of lowercase name + // STARTSWITH(LOWER(c.name), first3) → always true (string starts with its own prefix) + var results = await Query( + "SELECT * FROM c WHERE STARTSWITH(LOWER(c.name), SUBSTRING(LOWER(c.name), 0, 3))"); + + results.Should().HaveCount(3, "every string starts with its own first 3 chars"); + } + + // ── CONTAINS(LOWER(c.name), 'alice') in SELECT VALUE (not WHERE) ── + // This tests that the expression evaluation works in projection too + + [Fact] + public async Task NestedFunction_InSelectProjection() + { + await Seed(); + var results = await Query( + "SELECT CONTAINS(LOWER(c.name), 'alice') AS hasAlice FROM c WHERE c.id = '1'"); + + results.Should().HaveCount(1); + results[0]["hasAlice"]!.Value().Should().BeTrue(); + } + + [Fact] + public async Task NestedFunction_InSelectProjection_FalseCase() + { + await Seed(); + var results = await Query( + "SELECT CONTAINS(LOWER(c.name), 'alice') AS hasAlice FROM c WHERE c.id = '2'"); + + results.Should().HaveCount(1); + results[0]["hasAlice"]!.Value().Should().BeFalse(); + } } From e5dd5697accb27e81fd3cef6dcadd8cb7e031db5 Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 19:09:20 +0100 Subject: [PATCH 5/7] Fix UniqueKeyPolicy enforcement for nested paths (Issues #10, #13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ValidateUniqueKeys method used p.TrimStart('/') to convert Cosmos DB paths (e.g. /value/code) for JObject.SelectToken, yielding 'value/code'. Newtonsoft.Json's SelectToken uses dot notation ('value.code'), not forward slashes, so nested paths always resolved to null — causing false positive conflicts (every second insert conflicted regardless of values). Fix: add CosmosPathToSelectTokenPath() helper that splits on '/' and delegates to the existing BuildSelectTokenPath() which produces correct JSONPath notation (e.g. 'value.code', 'items[0].name'). Added 11 tests covering: - Nested paths (/value/code) — conflict detection and non-conflict - Deeply nested paths (/a/b/c) - Cross-partition isolation with nested paths - Upsert and Replace with nested paths - Container creation via Database.CreateContainerAsync - Container creation via Database.CreateContainerIfNotExistsAsync All 7762 existing tests continue to pass. Fixes #10 Fixes #13 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...osmosDB.InMemoryEmulator.JsTriggers.csproj | 2 +- ...MemoryEmulator.ProductionExtensions.csproj | 2 +- .../CosmosDB.InMemoryEmulator.csproj | 2 +- .../InMemoryContainer.cs | 13 +- .../ContainerManagementTests.cs | 293 ++++++++++++++++++ 5 files changed, 307 insertions(+), 5 deletions(-) diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj index bc8f082..caf67a9 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.JsTriggers - 2.0.185 + 2.0.186 lemonlion JavaScript trigger body interpretation for CosmosDB.InMemoryEmulator. Uses Jint to execute real Cosmos DB trigger JavaScript (getContext, getRequest, getResponse, getBody, setBody) inside your in-memory tests. MIT diff --git a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj index c9ffda9..0719d04 100644 --- a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj +++ b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.ProductionExtensions - 2.0.185 + 2.0.186 Production-safe extension methods for Azure Cosmos DB that enable seamless in-memory testing via CosmosDB.InMemoryEmulator. true diff --git a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj index a46a26e..7141991 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj +++ b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator - 2.0.185 + 2.0.186 lemonlion A high-fidelity, in-memory implementation of the Azure Cosmos DB SDK for .NET — purpose-built for fast, reliable component and integration testing. Drop-in replacements for CosmosClient, Container, and Database with full CRUD, SQL queries, LINQ, patch, bulk operations, batches, change feed, fault injection, and DI integration. Zero production code changes required with FakeCosmosHandler. MIT diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index 3e6444a..e094dbf 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -3297,7 +3297,7 @@ private void ValidateUniqueKeys(JObject jObj, string partitionKey, string exclud foreach (var uniqueKey in policy.UniqueKeys) { - var newValues = uniqueKey.Paths.Select(p => jObj.SelectToken(p.TrimStart('/'))?.ToString()).ToList(); + var newValues = uniqueKey.Paths.Select(p => jObj.SelectToken(CosmosPathToSelectTokenPath(p))?.ToString()).ToList(); foreach (var (existingKey, existingJson) in _items) { @@ -3305,7 +3305,7 @@ private void ValidateUniqueKeys(JObject jObj, string partitionKey, string exclud if (excludeItemId != null && existingKey.Id == excludeItemId) continue; var existingObj = JsonParseHelpers.ParseJson(existingJson); - var existingValues = uniqueKey.Paths.Select(p => existingObj.SelectToken(p.TrimStart('/'))?.ToString()).ToList(); + var existingValues = uniqueKey.Paths.Select(p => existingObj.SelectToken(CosmosPathToSelectTokenPath(p))?.ToString()).ToList(); if (newValues.SequenceEqual(existingValues)) { @@ -3317,6 +3317,15 @@ private void ValidateUniqueKeys(JObject jObj, string partitionKey, string exclud } } + /// + /// Converts a Cosmos DB path (e.g. "/value/code") to a Newtonsoft.Json SelectToken path (e.g. "value.code"). + /// + private static string CosmosPathToSelectTokenPath(string cosmosPath) + { + var segments = cosmosPath.TrimStart('/').Split('/'); + return BuildSelectTokenPath(segments); + } + private bool ValidateUniqueKeysStream(JObject jObj, string partitionKey, string excludeItemId = null) { try diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementTests.cs index 5f57b18..f67d9dd 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/ContainerManagementTests.cs @@ -539,6 +539,299 @@ await container.CreateItemAsync( } } +// ─── UniqueKeyPolicy with Nested Paths (Issue #10) ───────────────────── + +public class UniqueKeyPolicyNestedPathTests +{ + [Fact] + public async Task CreateItem_NestedPath_ViolatesUniqueKey_ThrowsConflict() + { + // Issue #10: /value/code nested path should be enforced + var properties = new ContainerProperties("unique-ctr", "/_partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_NestedPath_DifferentValues_Succeeds() + { + var properties = new ContainerProperties("unique-ctr", "/_partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "ABC" } }), + new PartitionKey("ref")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "XYZ" } }), + new PartitionKey("ref")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CreateItem_NestedPath_DifferentPartition_Succeeds() + { + var properties = new ContainerProperties("unique-ctr", "/_partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref1", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref1")); + + // Same nested value but different partition — should succeed + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref2", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref2")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task UpsertItem_NestedPath_ViolatesUniqueKey_ThrowsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/_partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReplaceItem_NestedPath_ViolatesUniqueKey_ThrowsConflict() + { + var properties = new ContainerProperties("unique-ctr", "/_partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "A" } }), + new PartitionKey("ref")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "B" } }), + new PartitionKey("ref")); + + // Replace item 2 to collide with item 1 + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "A" } }), + "2", new PartitionKey("ref")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_DeeplyNestedPath_ViolatesUniqueKey_ThrowsConflict() + { + // Three-level nesting: /a/b/c + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/a/b/c" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", a = new { b = new { c = "deep" } } }), + new PartitionKey("p")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "p", a = new { b = new { c = "deep" } } }), + new PartitionKey("p")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_DeeplyNestedPath_DifferentValues_Succeeds() + { + var properties = new ContainerProperties("unique-ctr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/a/b/c" } } } + } + }; + var container = new InMemoryContainer(properties); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", a = new { b = new { c = "val1" } } }), + new PartitionKey("p")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "p", a = new { b = new { c = "val2" } } }), + new PartitionKey("p")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── UniqueKeyPolicy via Database.CreateContainerAsync (Issue #13) ────── + +public class UniqueKeyPolicyViaDatabaseTests +{ + [Fact] + public async Task CreateItem_ViaDatabase_ViolatesUniqueKey_ThrowsConflict() + { + // Issue #13: UniqueKeyPolicy should be enforced when container is created + // via database.CreateContainerAsync(containerProperties) + var client = new InMemoryCosmosClient(); + var database = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + + var containerProps = new ContainerProperties("items", "/category") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = (await database.CreateContainerAsync(containerProps)).Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_ViaDatabase_NestedPath_ViolatesUniqueKey_ThrowsConflict() + { + // Combined Issue #10 + #13: nested paths via database.CreateContainerAsync + var client = new InMemoryCosmosClient(); + var database = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + + var containerProps = new ContainerProperties("refdata", "/_partitionKey") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + } + }; + var container = (await database.CreateContainerAsync(containerProps)).Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task UpsertItem_ViaDatabase_ViolatesUniqueKey_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + var database = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + + var containerProps = new ContainerProperties("items", "/category") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = (await database.CreateContainerAsync(containerProps)).Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "2", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_ViaCreateIfNotExists_ViolatesUniqueKey_ThrowsConflict() + { + var client = new InMemoryCosmosClient(); + var database = (await client.CreateDatabaseIfNotExistsAsync("testdb")).Database; + + var containerProps = new ContainerProperties("items", "/category") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + } + }; + var container = (await database.CreateContainerIfNotExistsAsync(containerProps)).Container; + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + // ─── ConflictResolutionPolicy Stored But Not Enforced ─────────────────── public class ConflictResolutionPolicyTests From 8c1f9478a1b008af3f1d67dcff5481586c72169b Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Wed, 15 Apr 2026 19:26:36 +0100 Subject: [PATCH 6/7] Fix additional UniqueKeyPolicy bugs found by QA edge case testing - Null/missing unique key values: Allow multiple items with null/missing unique key properties (matching real Cosmos DB behavior). Previously, two nulls compared equal causing false positive conflicts. - Type coercion: Use JToken type-aware comparison instead of .ToString(). Integer 42 vs string '42' no longer falsely conflict. Numeric types (Integer/Float) with equal values (e.g. 1 vs 1.0) correctly conflict. - TTL expired items: Skip expired-but-not-evicted items during unique key validation so they don't block reuse of their unique key values. - Special character property names: Use bracket notation (['my field']) in SelectToken paths for properties containing spaces, dots, or other special characters. - Add 41 comprehensive edge case tests covering null/missing values, type coercion, empty strings, nested objects, arrays, deeply nested paths, unicode, case sensitivity, special chars, TTL, concurrency, batch operations, patch operations, replace/upsert, and more. - Add BuildSelectTokenPath tests for bracket notation with special chars. Bump version to 2.0.187. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...osmosDB.InMemoryEmulator.JsTriggers.csproj | 2 +- ...MemoryEmulator.ProductionExtensions.csproj | 2 +- .../CosmosDB.InMemoryEmulator.csproj | 2 +- .../InMemoryContainer.cs | 54 +- .../BuildSelectTokenPathTests.cs | 21 + .../UniqueKeyEdgeCaseTests.cs | 1279 +++++++++++++++++ 6 files changed, 1354 insertions(+), 6 deletions(-) create mode 100644 tests/CosmosDB.InMemoryEmulator.Tests.Unit/UniqueKeyEdgeCaseTests.cs diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj index caf67a9..3ef648e 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.JsTriggers - 2.0.186 + 2.0.187 lemonlion JavaScript trigger body interpretation for CosmosDB.InMemoryEmulator. Uses Jint to execute real Cosmos DB trigger JavaScript (getContext, getRequest, getResponse, getBody, setBody) inside your in-memory tests. MIT diff --git a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj index 0719d04..bccd8f6 100644 --- a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj +++ b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator.ProductionExtensions - 2.0.186 + 2.0.187 Production-safe extension methods for Azure Cosmos DB that enable seamless in-memory testing via CosmosDB.InMemoryEmulator. true diff --git a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj index 7141991..caa588c 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj +++ b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj @@ -7,7 +7,7 @@ true true CosmosDB.InMemoryEmulator - 2.0.186 + 2.0.187 lemonlion A high-fidelity, in-memory implementation of the Azure Cosmos DB SDK for .NET — purpose-built for fast, reliable component and integration testing. Drop-in replacements for CosmosClient, Container, and Database with full CRUD, SQL queries, LINQ, patch, bulk operations, batches, change feed, fault injection, and DI integration. Zero production code changes required with FakeCosmosHandler. MIT diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index e094dbf..00355ee 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -3297,17 +3297,22 @@ private void ValidateUniqueKeys(JObject jObj, string partitionKey, string exclud foreach (var uniqueKey in policy.UniqueKeys) { - var newValues = uniqueKey.Paths.Select(p => jObj.SelectToken(CosmosPathToSelectTokenPath(p))?.ToString()).ToList(); + var newTokens = uniqueKey.Paths.Select(p => jObj.SelectToken(CosmosPathToSelectTokenPath(p))).ToList(); + + // Cosmos DB allows multiple items where ALL unique key paths are null/missing + if (newTokens.All(t => t is null || t.Type == JTokenType.Null)) + continue; foreach (var (existingKey, existingJson) in _items) { if (existingKey.PartitionKey != partitionKey) continue; if (excludeItemId != null && existingKey.Id == excludeItemId) continue; + if (IsExpired(existingKey)) continue; var existingObj = JsonParseHelpers.ParseJson(existingJson); - var existingValues = uniqueKey.Paths.Select(p => existingObj.SelectToken(CosmosPathToSelectTokenPath(p))?.ToString()).ToList(); + var existingTokens = uniqueKey.Paths.Select(p => existingObj.SelectToken(CosmosPathToSelectTokenPath(p))).ToList(); - if (newValues.SequenceEqual(existingValues)) + if (UniqueKeyTokensMatch(newTokens, existingTokens)) { throw new InMemoryCosmosException( "Unique index constraint violation.", @@ -3317,6 +3322,44 @@ private void ValidateUniqueKeys(JObject jObj, string partitionKey, string exclud } } + /// + /// Compares two lists of JTokens for unique key equality, preserving JSON type information. + /// Null/missing tokens are treated as equal to each other. Different JSON types (e.g. integer 42 vs string "42") + /// are treated as distinct values. Numeric types (Integer/Float) are compared by value so that 1 and 1.0 conflict. + /// This matches real Cosmos DB behavior. + /// + private static bool UniqueKeyTokensMatch(List newTokens, List existingTokens) + { + if (newTokens.Count != existingTokens.Count) return false; + + for (int i = 0; i < newTokens.Count; i++) + { + var a = newTokens[i]; + var b = existingTokens[i]; + + bool aNull = a is null || a.Type == JTokenType.Null; + bool bNull = b is null || b.Type == JTokenType.Null; + + if (aNull && bNull) continue; + if (aNull || bNull) return false; + + bool aNumeric = a.Type == JTokenType.Integer || a.Type == JTokenType.Float; + bool bNumeric = b.Type == JTokenType.Integer || b.Type == JTokenType.Float; + + if (aNumeric && bNumeric) + { + // Compare numeric values regardless of Integer/Float distinction + if ((double)a != (double)b) return false; + } + else + { + if (a.Type != b.Type) return false; + if (!JToken.DeepEquals(a, b)) return false; + } + } + return true; + } + /// /// Converts a Cosmos DB path (e.g. "/value/code") to a Newtonsoft.Json SelectToken path (e.g. "value.code"). /// @@ -8454,6 +8497,7 @@ private static void ApplyPatchOperations(JObject jObj, IReadOnlyList /// Converts path segments to a Newtonsoft.Json SelectToken-compatible path. /// Numeric segments become array indexers (e.g., ["runs","0"] → "runs[0]"). + /// Segments containing special characters (spaces, dots, etc.) use bracket notation. /// internal static string BuildSelectTokenPath(IEnumerable segments) { @@ -8464,6 +8508,10 @@ internal static string BuildSelectTokenPath(IEnumerable segments) { sb.Append('[').Append(segment).Append(']'); } + else if (segment.IndexOfAny(new[] { '.', ' ', '[', ']', '\'', '"' }) >= 0) + { + sb.Append("['").Append(segment).Append("']"); + } else { if (sb.Length > 0) sb.Append('.'); diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BuildSelectTokenPathTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BuildSelectTokenPathTests.cs index 24768b5..0760e1a 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BuildSelectTokenPathTests.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/BuildSelectTokenPathTests.cs @@ -60,4 +60,25 @@ public void TrailingNumeric_CorrectNotation() InMemoryContainer.BuildSelectTokenPath(new[] { "items", "0" }) .Should().Be("items[0]"); } + + [Fact] + public void SegmentWithSpace_UsesBracketNotation() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "my field" }) + .Should().Be("['my field']"); + } + + [Fact] + public void SegmentWithDot_UsesBracketNotation() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "my.field" }) + .Should().Be("['my.field']"); + } + + [Fact] + public void MixedNormalAndSpecialSegments_CorrectNotation() + { + InMemoryContainer.BuildSelectTokenPath(new[] { "parent", "my field", "child" }) + .Should().Be("parent['my field'].child"); + } } diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/UniqueKeyEdgeCaseTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/UniqueKeyEdgeCaseTests.cs new file mode 100644 index 0000000..d61028c --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/UniqueKeyEdgeCaseTests.cs @@ -0,0 +1,1279 @@ +using System.Net; +using System.Text; +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +// ═════════════════════════════════════════════════════════════════════════════ +// UniqueKeyPolicy edge-case / red-team tests +// ═════════════════════════════════════════════════════════════════════════════ + +// ─── 1. Null / Missing Properties ──────────────────────────────────────────── + +public class UniqueKeyEdgeCaseNullMissingTests +{ + private static InMemoryContainer CreateContainer(string path = "/email") + { + var props = new ContainerProperties("uk-null", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { path } } } + } + }; + return new InMemoryContainer(props); + } + + /// + /// Two items in the same partition that both lack the unique-key property. + /// In real Cosmos DB, missing properties are treated as null, and multiple + /// null values ARE allowed under a unique key constraint. + /// If the emulator wrongly conflicts on two nulls, this is a false-positive bug. + /// + [Fact] + public async Task TwoItems_BothMissingUniqueKeyProperty_ShouldNotConflict() + { + var container = CreateContainer(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // Second item also missing "email" — real Cosmos allows this + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a")); + + // Real Cosmos DB allows multiple items with missing (null) unique key values + await act.Should().NotThrowAsync(); + } + + /// + /// One item has the property, the other doesn't. + /// These should NOT conflict because their values differ (value vs null). + /// + [Fact] + public async Task OneItemHasProperty_OtherMissing_ShouldNotConflict() + { + var container = CreateContainer(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + /// + /// Both items have the property set to an explicit JSON null. + /// Real Cosmos DB allows multiple nulls under a unique key. + /// + [Fact] + public async Task TwoItems_BothExplicitNull_ShouldNotConflict() + { + var container = CreateContainer(); + + var item1 = JObject.FromObject(new { id = "1", pk = "a" }); + item1["email"] = JValue.CreateNull(); + + var item2 = JObject.FromObject(new { id = "2", pk = "a" }); + item2["email"] = JValue.CreateNull(); + + await container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => container.CreateItemAsync(item2, new PartitionKey("a")); + + // Real Cosmos DB allows multiple items with explicit null unique key values + await act.Should().NotThrowAsync(); + } + + /// + /// One item has a missing property and the other has explicit null. + /// In real Cosmos both are considered "null" and both are allowed. + /// + [Fact] + public async Task MissingProperty_VsExplicitNull_ShouldNotConflict() + { + var container = CreateContainer(); + + // item1: property is absent + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + // item2: property is explicit null + var item2 = JObject.FromObject(new { id = "2", pk = "a" }); + item2["email"] = JValue.CreateNull(); + + var act = () => container.CreateItemAsync(item2, new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 2. Type Coercion — .ToString() collapses types ───────────────────────── + +public class UniqueKeyEdgeCaseTypeCoercionTests +{ + private static InMemoryContainer CreateContainer() + { + var props = new ContainerProperties("uk-types", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/code" } } } + } + }; + return new InMemoryContainer(props); + } + + /// + /// Numeric 42 vs string "42". In real Cosmos these are different types and + /// should NOT conflict. But the emulator uses .ToString() which yields "42" + /// for both — potential false-positive conflict. + /// + [Fact] + public async Task NumericValue_VsStringValue_SameLiteral_ShouldNotConflict() + { + var container = CreateContainer(); + + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["code"] = 42 }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["code"] = "42" }; + + await container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => container.CreateItemAsync(item2, new PartitionKey("a")); + + // Real Cosmos treats number 42 and string "42" as different values + await act.Should().NotThrowAsync(); + } + + /// + /// Boolean true vs string "True". .ToString() on a bool JToken returns "True" + /// which matches the string "True" — false-positive conflict. + /// + [Fact] + public async Task BooleanTrue_VsStringTrue_ShouldNotConflict() + { + var container = CreateContainer(); + + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["code"] = true }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["code"] = "True" }; + + await container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => container.CreateItemAsync(item2, new PartitionKey("a")); + + // Different JSON types should not conflict + await act.Should().NotThrowAsync(); + } + + /// + /// Integer 1 vs boolean true. .ToString() yields "1" vs "True" — these won't + /// collide via ToString, but verify the emulator correctly allows both. + /// + [Fact] + public async Task Integer1_VsBoolTrue_ShouldNotConflict() + { + var container = CreateContainer(); + + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["code"] = 1 }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["code"] = true }; + + await container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => container.CreateItemAsync(item2, new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + /// + /// Float 1.0 vs integer 1. .ToString() might yield "1.0" vs "1" — they + /// shouldn't conflict even though mathematically equal, if Cosmos treats + /// them as different types. Actually, in real Cosmos numeric types may + /// be unified, so this verifies the current behavior. + /// + [Fact] + public async Task Float1_VsInteger1_ShouldConflict() + { + var container = CreateContainer(); + + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["code"] = 1 }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["code"] = 1.0 }; + + await container.CreateItemAsync(item1, new PartitionKey("a")); + + // Real Cosmos DB treats 1 and 1.0 as the same value for unique key purposes + var act = () => container.CreateItemAsync(item2, new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── 3. Empty String vs Null ───────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseEmptyStringTests +{ + private static InMemoryContainer CreateContainer() + { + var props = new ContainerProperties("uk-empty", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + return new InMemoryContainer(props); + } + + /// + /// Empty string "" vs missing property (null). + /// These should NOT conflict because "" is a string value while missing is null. + /// + [Fact] + public async Task EmptyString_VsMissingProperty_ShouldNotConflict() + { + var container = CreateContainer(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "" }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + /// + /// Two items with empty string "" — these SHOULD conflict because they have + /// the same non-null value. + /// + [Fact] + public async Task TwoEmptyStrings_ShouldConflict() + { + var container = CreateContainer(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "" }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── 4. Array Index Paths ──────────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseArrayPathTests +{ + /// + /// Path /tags/0 should resolve array index 0. + /// BuildSelectTokenPath converts "0" to "[0]", producing "tags[0]". + /// Verify this works end-to-end. + /// + [Fact] + public async Task ArrayIndexPath_ShouldResolveCorrectly() + { + var props = new ContainerProperties("uk-arr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/tags/0" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "alpha", "beta" } }), + new PartitionKey("a")); + + // Same first tag — should conflict + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", tags = new[] { "alpha", "gamma" } }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// Different array values at index 0 should not conflict. + /// + [Fact] + public async Task ArrayIndexPath_DifferentValues_ShouldNotConflict() + { + var props = new ContainerProperties("uk-arr2", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/tags/0" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", tags = new[] { "alpha" } }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", tags = new[] { "beta" } }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 5. Deeply Nested Objects ──────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseDeeplyNestedTests +{ + /// + /// Unique key on a 5-level deep nested path. + /// Verifies CosmosPathToSelectTokenPath handles many segments. + /// + [Fact] + public async Task DeeplyNestedPath_5Levels_ShouldEnforceUniqueness() + { + var props = new ContainerProperties("uk-deep", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/a/b/c/d/e" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", a = new { b = new { c = new { d = new { e = "deep-val" } } } } }), + new PartitionKey("a")); + + // Same deeply nested value — should conflict + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", a = new { b = new { c = new { d = new { e = "deep-val" } } } } }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// Different values at a deeply nested path should not conflict. + /// + [Fact] + public async Task DeeplyNestedPath_DifferentValues_ShouldNotConflict() + { + var props = new ContainerProperties("uk-deep2", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/a/b/c/d/e" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", a = new { b = new { c = new { d = new { e = "val1" } } } } }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", a = new { b = new { c = new { d = new { e = "val2" } } } } }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 6. Multiple UniqueKeys ────────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseMultipleKeysTests +{ + /// + /// Multiple UniqueKey entries with different paths — all should be enforced independently. + /// + [Fact] + public async Task MultipleUniqueKeys_AllEnforcedIndependently() + { + var props = new ContainerProperties("uk-multi", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = + { + new UniqueKey { Paths = { "/email" } }, + new UniqueKey { Paths = { "/phone" } } + } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "a@test.com", phone = "111" }), + new PartitionKey("a")); + + // Different email but same phone — should conflict on phone + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "b@test.com", phone = "111" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// Composite unique key (multiple paths in a single UniqueKey). + /// The combination must be unique, not each path individually. + /// + [Fact] + public async Task CompositeUniqueKey_SameCombination_ShouldConflict() + { + var props = new ContainerProperties("uk-comp", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = + { + new UniqueKey { Paths = { "/firstName", "/lastName" } } + } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", firstName = "John", lastName = "Doe" }), + new PartitionKey("a")); + + // Same firstName + lastName combo + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", firstName = "John", lastName = "Doe" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// Composite unique key — different combination should not conflict. + /// + [Fact] + public async Task CompositeUniqueKey_DifferentCombination_ShouldNotConflict() + { + var props = new ContainerProperties("uk-comp2", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = + { + new UniqueKey { Paths = { "/firstName", "/lastName" } } + } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", firstName = "John", lastName = "Doe" }), + new PartitionKey("a")); + + // Same firstName but different lastName + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", firstName = "John", lastName = "Smith" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 7. Partition Key Scoping ──────────────────────────────────────────────── + +public class UniqueKeyEdgeCasePartitionScopeTests +{ + /// + /// Unique keys are scoped per logical partition. + /// Same value in different partitions should NOT conflict. + /// + [Fact] + public async Task SameUniqueKeyValue_DifferentPartitions_ShouldNotConflict() + { + var props = new ContainerProperties("uk-pk", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "partition-A", email = "same@test.com" }), + new PartitionKey("partition-A")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "partition-B", email = "same@test.com" }), + new PartitionKey("partition-B")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 8. Delete + Recreate ──────────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseDeleteRecreateTests +{ + /// + /// After deleting an item, a new item with the same unique key value + /// should be accepted. + /// + [Fact] + public async Task DeletedItem_NewItemCanReuseUniqueKeyValue() + { + var props = new ContainerProperties("uk-del", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + await container.DeleteItemAsync("1", new PartitionKey("a")); + + // Should be able to reuse the email + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 9. Upsert Behavior ───────────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseUpsertTests +{ + private static InMemoryContainer CreateContainer() + { + var props = new ContainerProperties("uk-upsert", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + return new InMemoryContainer(props); + } + + /// + /// Upserting an existing item, changing the unique key to a value held by + /// another item, should throw Conflict. + /// + [Fact] + public async Task Upsert_ExistingItem_CollidesWithOtherItem_ShouldConflict() + { + var container = CreateContainer(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "bob@test.com" }), + new PartitionKey("a")); + + // Upsert item 2 with email of item 1 + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// Upserting an existing item keeping its own unique key value should succeed. + /// + [Fact] + public async Task Upsert_SameItem_SameUniqueKey_ShouldSucceed() + { + var container = CreateContainer(); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + var act = () => container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com", extra = "data" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 10. PatchItem Bypass ──────────────────────────────────────────────────── + +public class UniqueKeyEdgeCasePatchTests +{ + /// + /// Patching a unique-key property to match another item's value should conflict. + /// + [Fact] + public async Task PatchItem_SetsUniqueKeyToCollidingValue_ShouldConflict() + { + var props = new ContainerProperties("uk-patch", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "bob@test.com" }), + new PartitionKey("a")); + + var act = () => container.PatchItemAsync( + "2", new PartitionKey("a"), + new[] { PatchOperation.Set("/email", "alice@test.com") }); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// Patching a non-unique-key property should not trigger conflict. + /// + [Fact] + public async Task PatchItem_NonUniqueKeyProperty_ShouldSucceed() + { + var props = new ContainerProperties("uk-patch2", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com", name = "Alice" }), + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "bob@test.com", name = "Bob" }), + new PartitionKey("a")); + + var act = () => container.PatchItemAsync( + "2", new PartitionKey("a"), + new[] { PatchOperation.Set("/name", "Robert") }); + + await act.Should().NotThrowAsync(); + } + + /// + /// Removing a unique-key property via patch effectively sets it to null. + /// Should succeed even if another item also has null for that property + /// (real Cosmos allows multiple nulls). + /// + [Fact] + public async Task PatchItem_RemoveUniqueKeyProperty_ShouldSucceedIfNullAllowed() + { + var props = new ContainerProperties("uk-patch-rm", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), // no email = null + new PartitionKey("a")); + await container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "bob@test.com" }), + new PartitionKey("a")); + + // Remove email from item 2 — now both items have null email + var act = () => container.PatchItemAsync( + "2", new PartitionKey("a"), + new[] { PatchOperation.Remove("/email") }); + + // Should succeed because multiple nulls are allowed in real Cosmos + await act.Should().NotThrowAsync(); + } +} + +// ─── 11. Stream APIs ───────────────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseStreamTests +{ + private static InMemoryContainer CreateContainer() + { + var props = new ContainerProperties("uk-stream", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + return new InMemoryContainer(props); + } + + private static MemoryStream ToStream(object obj) => + new(Encoding.UTF8.GetBytes(JObject.FromObject(obj).ToString())); + + /// + /// CreateItemStreamAsync should enforce unique keys. + /// + [Fact] + public async Task CreateItemStream_DuplicateUniqueKey_ReturnsConflict() + { + var container = CreateContainer(); + + await container.CreateItemStreamAsync( + ToStream(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + var response = await container.CreateItemStreamAsync( + ToStream(new { id = "2", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// ReplaceItemStreamAsync should enforce unique keys. + /// + [Fact] + public async Task ReplaceItemStream_DuplicateUniqueKey_ReturnsConflict() + { + var container = CreateContainer(); + + await container.CreateItemStreamAsync( + ToStream(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + ToStream(new { id = "2", pk = "a", email = "bob@test.com" }), + new PartitionKey("a")); + + var response = await container.ReplaceItemStreamAsync( + ToStream(new { id = "2", pk = "a", email = "alice@test.com" }), + "2", new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// UpsertItemStreamAsync should enforce unique keys. + /// + [Fact] + public async Task UpsertItemStream_DuplicateUniqueKey_ReturnsConflict() + { + var container = CreateContainer(); + + await container.CreateItemStreamAsync( + ToStream(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + await container.CreateItemStreamAsync( + ToStream(new { id = "2", pk = "a", email = "bob@test.com" }), + new PartitionKey("a")); + + // Upsert item 2 with colliding email + var response = await container.UpsertItemStreamAsync( + ToStream(new { id = "2", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── 12. Transactional Batch ───────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseBatchTests +{ + /// + /// A transactional batch that creates two items with colliding unique keys + /// should fail the entire batch. + /// + [Fact] + public async Task Batch_TwoCreatesWithCollidingUniqueKey_ShouldFail() + { + var props = new ContainerProperties("uk-batch", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + var batch = container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "1", pk = "a", email = "same@test.com" })); + batch.CreateItem(JObject.FromObject(new { id = "2", pk = "a", email = "same@test.com" })); + + var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + } + + /// + /// A batch create that collides with an existing item should fail. + /// + [Fact] + public async Task Batch_CreateCollidesWithExistingItem_ShouldFail() + { + var props = new ContainerProperties("uk-batch2", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + var batch = container.CreateTransactionalBatch(new PartitionKey("a")); + batch.CreateItem(JObject.FromObject(new { id = "2", pk = "a", email = "alice@test.com" })); + + var response = await batch.ExecuteAsync(); + + response.IsSuccessStatusCode.Should().BeFalse(); + } +} + +// ─── 13. TTL-Expired Items ─────────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseTtlTests +{ + /// + /// If a container has TTL enabled and an item has expired but not yet been + /// evicted, it should NOT block a new item from taking its unique key value. + /// This tests whether the emulator correctly ignores expired items during + /// unique key validation. + /// + [Fact] + public async Task ExpiredItem_ShouldNotBlockNewItemWithSameUniqueKey() + { + var props = new ContainerProperties("uk-ttl", "/pk") + { + DefaultTimeToLive = 1, // 1 second TTL + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + // Wait for TTL to expire + await Task.Delay(1500); + + // New item with same email should be allowed since the old item expired + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 14. Hierarchical Partition Keys ───────────────────────────────────────── + +public class UniqueKeyEdgeCaseHierarchicalPartitionKeyTests +{ + /// + /// Unique keys with hierarchical (composite) partition keys. + /// Items in different logical partitions (different composite key) should + /// not conflict even with same unique key value. + /// + [Fact] + public async Task HierarchicalPartitionKey_DifferentPartitions_ShouldNotConflict() + { + var props = new ContainerProperties("uk-hier", new List { "/tenantId", "/region" }) + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", region = "us", email = "alice@test.com" }), + new PartitionKeyBuilder().Add("t1").Add("us").Build()); + + // Same email, different hierarchical partition + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenantId = "t1", region = "eu", email = "alice@test.com" }), + new PartitionKeyBuilder().Add("t1").Add("eu").Build()); + + await act.Should().NotThrowAsync(); + } + + /// + /// Same hierarchical partition — should conflict. + /// + [Fact] + public async Task HierarchicalPartitionKey_SamePartition_ShouldConflict() + { + var props = new ContainerProperties("uk-hier2", new List { "/tenantId", "/region" }) + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", tenantId = "t1", region = "us", email = "alice@test.com" }), + new PartitionKeyBuilder().Add("t1").Add("us").Build()); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", tenantId = "t1", region = "us", email = "alice@test.com" }), + new PartitionKeyBuilder().Add("t1").Add("us").Build()); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── 15. Case Sensitivity ──────────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseSensitivityTests +{ + /// + /// Unique key values should be case-sensitive. + /// "Alice" and "alice" should NOT conflict. + /// + [Fact] + public async Task UniqueKeyValues_AreCaseSensitive() + { + var props = new ContainerProperties("uk-case", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "Alice@test.com" }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + // Different case = different value in Cosmos DB + await act.Should().NotThrowAsync(); + } +} + +// ─── 16. Special Characters in Property Names ──────────────────────────────── + +public class UniqueKeyEdgeCaseSpecialCharTests +{ + /// + /// Property names with dots or special characters. + /// Cosmos paths use / as separator. A property named "my.field" would be + /// referenced as /my.field in Cosmos. After path conversion this becomes + /// "my.field" which SelectToken interprets as nested path "my" → "field". + /// This would incorrectly resolve the property. + /// + [Fact] + public async Task PropertyNameWithDot_PathResolvesCorrectly() + { + var props = new ContainerProperties("uk-dot", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/my.field" } } } + } + }; + var container = new InMemoryContainer(props); + + // Create item with property named "my.field" (single property, not nested) + var item1 = new JObject + { + ["id"] = "1", + ["pk"] = "a", + ["my.field"] = "value1" + }; + + await container.CreateItemAsync(item1, new PartitionKey("a")); + + // Same "my.field" value + var item2 = new JObject + { + ["id"] = "2", + ["pk"] = "a", + ["my.field"] = "value1" + }; + + var act = () => container.CreateItemAsync(item2, new PartitionKey("a")); + + // Should conflict because both items have the same value for "my.field" + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// Property name with spaces. + /// Path "/my field" → "my field" in SelectToken, which should work with SelectToken. + /// + [Fact] + public async Task PropertyNameWithSpace_ShouldResolveCorrectly() + { + var props = new ContainerProperties("uk-space", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/my field" } } } + } + }; + var container = new InMemoryContainer(props); + + var item1 = new JObject + { + ["id"] = "1", + ["pk"] = "a", + ["my field"] = "value1" + }; + + await container.CreateItemAsync(item1, new PartitionKey("a")); + + var item2 = new JObject + { + ["id"] = "2", + ["pk"] = "a", + ["my field"] = "value1" + }; + + var act = () => container.CreateItemAsync(item2, new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── 17. Replace/Upsert Self-Update ───────────────────────────────────────── + +public class UniqueKeyEdgeCaseSelfUpdateTests +{ + /// + /// Replacing an item should not conflict with its own current unique key value. + /// + [Fact] + public async Task Replace_SameItem_SameUniqueKey_ShouldSucceed() + { + var props = new ContainerProperties("uk-self", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com", name = "Alice" }), + new PartitionKey("a")); + + // Replace the same item, keeping the same email + var act = () => container.ReplaceItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com", name = "Alice Updated" }), + "1", new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 18. Nested Path with Array Index Combo ────────────────────────────────── + +public class UniqueKeyEdgeCaseNestedArrayComboTests +{ + /// + /// Path like /data/items/0/code — combining nested objects with array index. + /// + [Fact] + public async Task NestedObjectWithArrayIndex_ShouldResolve() + { + var props = new ContainerProperties("uk-nested-arr", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/data/items/0/code" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", data = new { items = new[] { new { code = "ABC" } } } }), + new PartitionKey("a")); + + // Same code at data.items[0].code + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", data = new { items = new[] { new { code = "ABC" } } } }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── 19. Concurrent Writes ────────────────────────────────────────────────── + +public class UniqueKeyEdgeCaseConcurrencyTests +{ + /// + /// Many concurrent creates with the same unique key value — exactly one should succeed, + /// the rest should fail with conflict or duplicate id. + /// + [Fact] + public async Task ConcurrentCreates_SameUniqueKey_OnlyOneSucceeds() + { + var props = new ContainerProperties("uk-conc", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + var tasks = Enumerable.Range(0, 20).Select(i => + Task.Run(async () => + { + try + { + await container.CreateItemAsync( + JObject.FromObject(new { id = i.ToString(), pk = "a", email = "race@test.com" }), + new PartitionKey("a")); + return true; + } + catch (CosmosException) + { + return false; + } + })).ToArray(); + + var results = await Task.WhenAll(tasks); + var successCount = results.Count(r => r); + + // Exactly one should succeed + successCount.Should().Be(1); + } + + /// + /// Concurrent creates with different unique key values should all succeed. + /// + [Fact] + public async Task ConcurrentCreates_DifferentUniqueKeys_AllSucceed() + { + var props = new ContainerProperties("uk-conc2", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + } + }; + var container = new InMemoryContainer(props); + + var tasks = Enumerable.Range(0, 20).Select(i => + Task.Run(async () => + { + await container.CreateItemAsync( + JObject.FromObject(new { id = i.ToString(), pk = "a", email = $"user{i}@test.com" }), + new PartitionKey("a")); + })).ToArray(); + + var act = () => Task.WhenAll(tasks); + + await act.Should().NotThrowAsync(); + } +} + +// ─── 20. Nested Path Unique Key via Cosmos Path Format ─────────────────────── + +public class UniqueKeyEdgeCaseCosmosPathFormatTests +{ + /// + /// The core bug that was fixed: Cosmos paths like "/value/code" must be + /// converted to "value.code" for SelectToken. Verify this works. + /// + [Fact] + public async Task NestedCosmosPath_EnforcesUniqueness() + { + var props = new ContainerProperties("uk-nested-path", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", value = new { code = "X" } }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", value = new { code = "X" } }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + /// + /// Different nested values should not conflict. + /// + [Fact] + public async Task NestedCosmosPath_DifferentValues_ShouldNotConflict() + { + var props = new ContainerProperties("uk-nested-path2", "/pk") + { + UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + } + }; + var container = new InMemoryContainer(props); + + await container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", value = new { code = "X" } }), + new PartitionKey("a")); + + var act = () => container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", value = new { code = "Y" } }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} From e0a789fcf420de92b3a748fb154d0d330c3f279d Mon Sep 17 00:00:00 2001 From: McNultyyy Date: Tue, 21 Apr 2026 22:46:24 +0100 Subject: [PATCH 7/7] feat: add UniqueKeyPolicy integration tests and fixture support - Add UniqueKeyPolicy property to IContainerTestSetup interface - Implement UniqueKeyPolicy getter/setter in InMemoryContainer - Update InMemoryTestFixture.ApplyContainerProperties to copy UniqueKeyPolicy - Add 32 integration tests covering: nested paths (issue #10), database creation (issue #13), null/missing values, type coercion, composite keys, multiple independent keys, string variants, TTL interaction, upsert self - Bump version to 4.0.6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...osmosDB.InMemoryEmulator.JsTriggers.csproj | 11 - ...MemoryEmulator.ProductionExtensions.csproj | 10 +- .../CosmosDB.InMemoryEmulator.csproj | 14 +- .../IContainerTestSetup.cs | 6 + .../InMemoryContainer.cs | 10 + src/Directory.Build.props | 2 +- .../UniqueKeyPolicyTests.cs | 730 ++++++++++++++++++ .../Infrastructure/InMemoryTestFixture.cs | 3 + 8 files changed, 755 insertions(+), 31 deletions(-) create mode 100644 tests/CosmosDB.InMemoryEmulator.Tests.Integration/UniqueKeyPolicyTests.cs diff --git a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj index 3ef648e..16ec78e 100644 --- a/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj +++ b/src/CosmosDB.InMemoryEmulator.JsTriggers/CosmosDB.InMemoryEmulator.JsTriggers.csproj @@ -1,19 +1,8 @@ - net8.0;net10.0 - enable - enable - true - true CosmosDB.InMemoryEmulator.JsTriggers - 2.0.187 - lemonlion JavaScript trigger body interpretation for CosmosDB.InMemoryEmulator. Uses Jint to execute real Cosmos DB trigger JavaScript (getContext, getRequest, getResponse, getBody, setBody) inside your in-memory tests. - MIT - https://github.com/lemonlion/CosmosDB.InMemoryEmulator - https://github.com/lemonlion/CosmosDB.InMemoryEmulator - git cosmosdb;cosmos;azure;testing;triggers;javascript;jint;in-memory;emulator diff --git a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj index bccd8f6..8f20431 100644 --- a/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj +++ b/src/CosmosDB.InMemoryEmulator.ProductionExtensions/CosmosDB.InMemoryEmulator.ProductionExtensions.csproj @@ -1,15 +1,11 @@ - net8.0;net10.0 - enable - enable - true - true CosmosDB.InMemoryEmulator.ProductionExtensions - 2.0.187 - Production-safe extension methods for Azure Cosmos DB that enable seamless in-memory testing via CosmosDB.InMemoryEmulator. + DEPRECATED — No longer needed since 4.0. All approaches now use FakeCosmosHandler which handles .ToFeedIterator() natively. + cosmosdb;cosmos;azure;testing;in-memory;emulator;production;extensions;feed-iterator;deprecated true + false diff --git a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj index caa588c..ec105c7 100644 --- a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj +++ b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj @@ -1,19 +1,8 @@ - net8.0;net10.0 - enable - enable - true - true CosmosDB.InMemoryEmulator - 2.0.187 - lemonlion A high-fidelity, in-memory implementation of the Azure Cosmos DB SDK for .NET — purpose-built for fast, reliable component and integration testing. Drop-in replacements for CosmosClient, Container, and Database with full CRUD, SQL queries, LINQ, patch, bulk operations, batches, change feed, fault injection, and DI integration. Zero production code changes required with FakeCosmosHandler. - MIT - https://github.com/lemonlion/CosmosDB.InMemoryEmulator - https://github.com/lemonlion/CosmosDB.InMemoryEmulator - git cosmosdb;cosmos;azure;testing;in-memory;emulator;mock;fake;integration-testing;component-testing README.md @@ -23,7 +12,8 @@ - + + diff --git a/src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs b/src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs index c8d2908..8f123d9 100644 --- a/src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs +++ b/src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs @@ -95,6 +95,12 @@ void RegisterTrigger(string id, TriggerType type, TriggerOperation operation, /// int? DefaultTimeToLive { get; set; } + /// + /// The unique key policy for this container. Set to configure unique key constraints + /// that enforce uniqueness of one or more values within a logical partition. + /// + UniqueKeyPolicy UniqueKeyPolicy { get; set; } + /// /// Number of feed ranges returned by GetFeedRangesAsync. Defaults to 1. /// Set to a higher value to simulate multiple physical partitions. diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs index 00355ee..cfb770a 100644 --- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs +++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs @@ -305,6 +305,16 @@ public int? DefaultTimeToLive } private int? _defaultTimeToLive; + /// + /// The unique key policy for this container. Set to configure unique key constraints + /// that enforce uniqueness of one or more values within a logical partition. + /// + public UniqueKeyPolicy UniqueKeyPolicy + { + get => _containerProperties.UniqueKeyPolicy; + set => _containerProperties.UniqueKeyPolicy = value ?? new UniqueKeyPolicy(); + } + /// The partition key path(s) for this container. public IReadOnlyList PartitionKeyPaths { get; } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0d197fa..478c359 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ true - 4.0.5 + 4.0.6 lemonlion Copyright (c) 2026 lemonlion MIT diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Integration/UniqueKeyPolicyTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/UniqueKeyPolicyTests.cs new file mode 100644 index 0000000..f600420 --- /dev/null +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Integration/UniqueKeyPolicyTests.cs @@ -0,0 +1,730 @@ +using System.Net; +using AwesomeAssertions; +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace CosmosDB.InMemoryEmulator.Tests; + +// ═════════════════════════════════════════════════════════════════════════════ +// UniqueKeyPolicy integration tests — issues #10 & #13 +// Validates unique-key enforcement through the full SDK HTTP pipeline. +// Parity-validated: runs against both FakeCosmosHandler and real emulator. +// ═════════════════════════════════════════════════════════════════════════════ + +// ─── Nested-path unique key enforcement (Issue #10) ───────────────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyNestedPathIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-nested", "/_partitionKey", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task CreateItem_NestedPath_DuplicateValue_ThrowsConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_NestedPath_DifferentValues_Succeeds() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "ABC" } }), + new PartitionKey("ref")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "XYZ" } }), + new PartitionKey("ref")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CreateItem_NestedPath_DifferentPartitions_Succeeds() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref1", value = new { code = "SAME" } }), + new PartitionKey("ref1")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref2", value = new { code = "SAME" } }), + new PartitionKey("ref2")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task UpsertItem_NestedPath_DuplicateValue_ThrowsConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "DUP" } }), + new PartitionKey("ref")); + + var act = () => _container.UpsertItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "DUP" } }), + new PartitionKey("ref")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task ReplaceItem_NestedPath_CollidesWithExisting_ThrowsConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "A" } }), + new PartitionKey("ref")); + await _container.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "B" } }), + new PartitionKey("ref")); + + var act = () => _container.ReplaceItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "A" } }), + "2", new PartitionKey("ref")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── Deeply nested path (3+ levels) ───────────────────────────────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyDeeplyNestedIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-deep", "/pk", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/a/b/c" } } } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task CreateItem_ThreeLevelNesting_DuplicateValue_ThrowsConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", a = new { b = new { c = "deep" } } }), + new PartitionKey("p")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "p", a = new { b = new { c = "deep" } } }), + new PartitionKey("p")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_ThreeLevelNesting_DifferentValues_Succeeds() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "p", a = new { b = new { c = "val1" } } }), + new PartitionKey("p")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "p", a = new { b = new { c = "val2" } } }), + new PartitionKey("p")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── UniqueKeyPolicy via Database.CreateContainerAsync (Issue #13) ────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyViaDatabaseIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-database", "/category", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/name" } } } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task CreateItem_ViaFixture_DuplicateValue_ThrowsConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_ViaFixture_NestedPath_DuplicateValue_ThrowsConflict() + { + // Uses a separate container with nested path + var container2 = await _fixture.CreateContainerAsync("uk-database-nested", "/_partitionKey", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/value/code" } } } + }; + }); + + await container2.CreateItemAsync( + JObject.FromObject(new { id = "1", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var act = () => container2.CreateItemAsync( + JObject.FromObject(new { id = "2", _partitionKey = "ref", value = new { code = "DUPLICATE" } }), + new PartitionKey("ref")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task UpsertItem_ViaFixture_DuplicateValue_ThrowsConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var act = () => _container.UpsertItemAsync( + JObject.FromObject(new { id = "2", category = "books", name = "Clean Code" }), + new PartitionKey("books")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task CreateItem_ViaFixture_DifferentValues_Succeeds() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", category = "books", name = "Book A" }), + new PartitionKey("books")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", category = "books", name = "Book B" }), + new PartitionKey("books")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── Null / Missing unique key values ─────────────────────────────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyNullMissingIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-null", "/pk", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task TwoItems_BothMissingUniqueKeyProperty_ShouldNotConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task OneItemHasProperty_OtherMissing_ShouldNotConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "alice@test.com" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task TwoItems_BothExplicitNull_ShouldNotConflict() + { + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["email"] = JValue.CreateNull() }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["email"] = JValue.CreateNull() }; + + await _container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => _container.CreateItemAsync(item2, new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task OneItemExplicitNull_OtherMissing_ShouldNotConflict() + { + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["email"] = JValue.CreateNull() }; + var item2 = JObject.FromObject(new { id = "2", pk = "a" }); + + await _container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => _container.CreateItemAsync(item2, new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── Type coercion — different JSON types should not conflict ──────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyTypeCoercionIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-types", "/pk", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/code" } } } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task NumericAndString_SameLiteral_ShouldNotConflict() + { + // Numeric 42 vs string "42" — different types, should not conflict + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["code"] = 42 }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["code"] = "42" }; + + await _container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => _container.CreateItemAsync(item2, new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task BooleanAndString_SameLiteral_ShouldNotConflict() + { + // Boolean true vs string "true" — different types + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["code"] = true }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["code"] = "true" }; + + await _container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => _container.CreateItemAsync(item2, new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task IntegerAndFloat_SameNumericValue_ShouldConflict() + { + // 1 and 1.0 are the same numeric value — should conflict + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["code"] = 1 }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["code"] = 1.0 }; + + await _container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => _container.CreateItemAsync(item2, new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task TwoIdenticalStrings_ShouldConflict() + { + var item1 = new JObject { ["id"] = "1", ["pk"] = "a", ["code"] = "hello" }; + var item2 = new JObject { ["id"] = "2", ["pk"] = "a", ["code"] = "hello" }; + + await _container.CreateItemAsync(item1, new PartitionKey("a")); + + var act = () => _container.CreateItemAsync(item2, new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── Composite unique keys (multiple paths in one UniqueKey) ───────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyCompositeIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-composite", "/pk", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = + { + new UniqueKey { Paths = { "/firstName", "/lastName" } } + } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task SameFirstAndLastName_ShouldConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", firstName = "John", lastName = "Doe" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", firstName = "John", lastName = "Doe" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task SameFirstName_DifferentLastName_ShouldSucceed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", firstName = "John", lastName = "Doe" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", firstName = "John", lastName = "Smith" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task BothItemsMissingAllCompositePaths_ShouldNotConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── Multiple independent unique keys ─────────────────────────────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyMultipleKeysIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-multi", "/pk", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = + { + new UniqueKey { Paths = { "/email" } }, + new UniqueKey { Paths = { "/username" } } + } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task DuplicateEmail_ShouldConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "a@b.com", username = "user1" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "a@b.com", username = "user2" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task DuplicateUsername_ShouldConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "a@b.com", username = "user1" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "x@y.com", username = "user1" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task BothUnique_ShouldSucceed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "a@b.com", username = "user1" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", email = "x@y.com", username = "user2" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── Empty string, unicode, case-sensitivity ──────────────────────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyStringVariantsIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-strings", "/pk", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/code" } } } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task TwoEmptyStrings_ShouldConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", code = "" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", code = "" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } + + [Fact] + public async Task EmptyStringVsNull_ShouldNotConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", code = "" }), + new PartitionKey("a")); + + // Second item missing "code" (null) + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CaseSensitive_DifferentCase_ShouldNotConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", code = "Hello" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", code = "hello" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task UnicodeValues_Identical_ShouldConflict() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", code = "日本語" }), + new PartitionKey("a")); + + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", code = "日本語" }), + new PartitionKey("a")); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(HttpStatusCode.Conflict); + } +} + +// ─── TTL interaction with unique keys ─────────────────────────────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyTtlIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-ttl", "/pk", + configure: props => + { + props.DefaultTimeToLive = 2; + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/code" } } } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task ExpiredItem_ShouldNotBlockNewItemWithSameValue() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", code = "EXPIRE_ME" }), + new PartitionKey("a")); + + // Wait for TTL to expire + await Task.Delay(3000); + + // Should succeed — expired item should not block unique key + var act = () => _container.CreateItemAsync( + JObject.FromObject(new { id = "2", pk = "a", code = "EXPIRE_ME" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} + +// ─── Upsert self-update (same id) should not conflict ─────────────────────── + +[Collection(IntegrationCollection.Name)] +public class UniqueKeyPolicyUpsertSelfIntegrationTests(EmulatorSession session) : IAsyncLifetime +{ + private readonly ITestContainerFixture _fixture = TestFixtureFactory.Create(session); + private Container _container = null!; + + public async ValueTask InitializeAsync() + { + _container = await _fixture.CreateContainerAsync("uk-upsert-self", "/pk", + configure: props => + { + props.UniqueKeyPolicy = new UniqueKeyPolicy + { + UniqueKeys = { new UniqueKey { Paths = { "/email" } } } + }; + }); + } + + public async ValueTask DisposeAsync() => await _fixture.DisposeAsync(); + + [Fact] + public async Task UpsertSameItem_SameValue_ShouldSucceed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "test@test.com" }), + new PartitionKey("a")); + + // Upsert same id with same unique value — should succeed (self-update) + var act = () => _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "test@test.com", name = "updated" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task UpsertSameItem_ChangedUniqueValue_ShouldSucceed() + { + await _container.CreateItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "old@test.com" }), + new PartitionKey("a")); + + // Upsert same id with new unique value — should succeed + var act = () => _container.UpsertItemAsync( + JObject.FromObject(new { id = "1", pk = "a", email = "new@test.com" }), + new PartitionKey("a")); + + await act.Should().NotThrowAsync(); + } +} diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/InMemoryTestFixture.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/InMemoryTestFixture.cs index 6ef7ccd..0e5b2ae 100644 --- a/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/InMemoryTestFixture.cs +++ b/tests/CosmosDB.InMemoryEmulator.Tests.Shared/Infrastructure/InMemoryTestFixture.cs @@ -53,6 +53,9 @@ private static void ApplyContainerProperties( // Apply the captured settings to the IContainerTestSetup if (props.DefaultTimeToLive.HasValue) setup.DefaultTimeToLive = props.DefaultTimeToLive; + + if (props.UniqueKeyPolicy?.UniqueKeys.Count > 0) + setup.UniqueKeyPolicy = props.UniqueKeyPolicy; } public ValueTask DisposeAsync()