diff --git a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs
index db80734..b95f4ec 100644
--- a/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs
+++ b/src/CosmosDB.InMemoryEmulator/CosmosSqlParser.cs
@@ -969,6 +969,24 @@ public static WhereExpression ToWhereExpression(SqlExpression expr)
return new SqlExpressionCondition(func);
}
+ var hasNestedFunctions = func.Arguments.Any(ContainsFunctionCall);
+ if (hasNestedFunctions)
+ {
+ 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/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 f259e0d..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; }
@@ -3297,17 +3307,22 @@ 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 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(p.TrimStart('/'))?.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 +3332,53 @@ 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").
+ ///
+ 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
@@ -5962,6 +6024,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);
@@ -6312,7 +6379,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":
@@ -6822,6 +6889,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);
}
@@ -8435,6 +8507,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)
{
@@ -8445,6 +8518,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/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()
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/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
diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs
index 0a91cdd..c0ae6f0 100644
--- a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs
+++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/SqlFunctionTests.cs
@@ -3053,4 +3053,563 @@ 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");
}
+
+ [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");
+ }
+
+ // ── 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");
+ }
+
+ // ── 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");
+ }
+
+ // ══════════════════════════════════════════════════════════════════════════
+ // 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();
+ }
}
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();
+ }
+}