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(); + } +}